Merge branch 'main' into dev

This commit is contained in:
Mohamed Boudra
2026-04-10 14:44:57 +07:00
parent 4176bd8aa1
commit 0deaed3794
22 changed files with 923 additions and 160 deletions
@@ -28,6 +28,10 @@ import type {
CheckoutPushResponse,
CheckoutPrCreateResponse,
CheckoutPrStatusResponse,
CheckoutSwitchBranchResponse,
StashSaveResponse,
StashPopResponse,
StashListResponse,
ValidateBranchResponse,
BranchSuggestionsResponse,
GitHubSearchResponse,
@@ -224,6 +228,10 @@ type CheckoutMergeFromBasePayload = CheckoutMergeFromBaseResponse["payload"];
type CheckoutPushPayload = CheckoutPushResponse["payload"];
type CheckoutPrCreatePayload = CheckoutPrCreateResponse["payload"];
type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
type CheckoutSwitchBranchPayload = CheckoutSwitchBranchResponse["payload"];
type StashSavePayload = StashSaveResponse["payload"];
type StashPopPayload = StashPopResponse["payload"];
type StashListPayload = StashListResponse["payload"];
type ValidateBranchPayload = ValidateBranchResponse["payload"];
type BranchSuggestionsPayload = BranchSuggestionsResponse["payload"];
type GitHubSearchPayload = GitHubSearchResponse["payload"];
@@ -2420,6 +2428,74 @@ export class DaemonClient {
});
}
async checkoutSwitchBranch(
cwd: string,
branch: string,
requestId?: string,
): Promise<CheckoutSwitchBranchPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "checkout_switch_branch_request",
cwd,
branch,
},
responseType: "checkout_switch_branch_response",
timeout: 30000,
});
}
async stashSave(
cwd: string,
options?: { branch?: string },
requestId?: string,
): Promise<StashSavePayload> {
return this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "stash_save_request",
cwd,
branch: options?.branch,
},
responseType: "stash_save_response",
timeout: 30000,
});
}
async stashPop(
cwd: string,
stashIndex: number,
requestId?: string,
): Promise<StashPopPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "stash_pop_request",
cwd,
stashIndex,
},
responseType: "stash_pop_response",
timeout: 30000,
});
}
async stashList(
cwd: string,
options?: { paseoOnly?: boolean },
requestId?: string,
): Promise<StashListPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "stash_list_request",
cwd,
paseoOnly: options?.paseoOnly,
},
responseType: "stash_list_response",
timeout: 10000,
});
}
async getPaseoWorktreeList(
input: { cwd?: string; repoRoot?: string },
requestId?: string,
+1 -1
View File
@@ -302,7 +302,7 @@ export async function createPaseoDaemon(
app.use((req, res, next) => {
const origin = req.headers.origin;
if (origin && allowedOrigins.has(origin)) {
if (origin && (allowedOrigins.has("*") || allowedOrigins.has(origin))) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
+150 -1
View File
@@ -1664,6 +1664,22 @@ export class Session {
this.handleUnsubscribeCheckoutDiffRequest(msg);
break;
case "checkout_switch_branch_request":
await this.handleCheckoutSwitchBranchRequest(msg);
break;
case "stash_save_request":
await this.handleStashSaveRequest(msg);
break;
case "stash_pop_request":
await this.handleStashPopRequest(msg);
break;
case "stash_list_request":
await this.handleStashListRequest(msg);
break;
case "checkout_commit_request":
await this.handleCheckoutCommitRequest(msg);
break;
@@ -4404,6 +4420,139 @@ export class Session {
this.checkoutDiffSubscriptions.delete(msg.subscriptionId);
}
private async handleCheckoutSwitchBranchRequest(
msg: Extract<SessionInboundMessage, { type: "checkout_switch_branch_request" }>,
): Promise<void> {
const { cwd, branch, requestId } = msg;
try {
await this.checkoutExistingBranch(cwd, branch);
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
// Push a workspace_update immediately so the sidebar/header reflect
// the new branch name without waiting for the background git watcher.
await this.emitWorkspaceUpdateForCwd(cwd);
this.emit({
type: "checkout_switch_branch_response",
payload: {
cwd,
success: true,
branch,
error: null,
requestId,
},
});
} catch (error) {
this.emit({
type: "checkout_switch_branch_response",
payload: {
cwd,
success: false,
branch,
error: toCheckoutError(error),
requestId,
},
});
}
}
// ---------------------------------------------------------------------------
// Stash handlers
// ---------------------------------------------------------------------------
private static readonly PASEO_STASH_PREFIX = "paseo-auto-stash:";
private async handleStashSaveRequest(
msg: Extract<SessionInboundMessage, { type: "stash_save_request" }>,
): Promise<void> {
const { cwd, requestId } = msg;
try {
const branchLabel = msg.branch?.trim() ?? "";
const message = branchLabel
? `${Session.PASEO_STASH_PREFIX} ${branchLabel}`
: `${Session.PASEO_STASH_PREFIX} unnamed`;
await execFileAsync("git", ["stash", "push", "--include-untracked", "-m", message], { cwd });
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
this.emit({
type: "stash_save_response",
payload: { cwd, success: true, error: null, requestId },
});
} catch (error) {
this.emit({
type: "stash_save_response",
payload: { cwd, success: false, error: toCheckoutError(error), requestId },
});
}
}
private async handleStashPopRequest(
msg: Extract<SessionInboundMessage, { type: "stash_pop_request" }>,
): Promise<void> {
const { cwd, stashIndex, requestId } = msg;
try {
await execFileAsync("git", ["stash", "pop", `stash@{${stashIndex}}`], { cwd });
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
this.emit({
type: "stash_pop_response",
payload: { cwd, success: true, error: null, requestId },
});
} catch (error) {
this.emit({
type: "stash_pop_response",
payload: { cwd, success: false, error: toCheckoutError(error), requestId },
});
}
}
private async handleStashListRequest(
msg: Extract<SessionInboundMessage, { type: "stash_list_request" }>,
): Promise<void> {
const { cwd, requestId } = msg;
const paseoOnly = msg.paseoOnly !== false;
try {
const { stdout } = await execAsync("git stash list --format=%gd%x00%s", {
cwd,
env: READ_ONLY_GIT_ENV,
});
const lines = stdout.trim().split("\n").filter(Boolean);
const entries: Array<{
index: number;
message: string;
branch: string | null;
isPaseo: boolean;
}> = [];
for (const line of lines) {
const sepIdx = line.indexOf("\0");
if (sepIdx < 0) continue;
const refPart = line.slice(0, sepIdx);
const subject = line.slice(sepIdx + 1);
const indexMatch = refPart.match(/\{(\d+)\}/);
if (!indexMatch) continue;
const index = Number(indexMatch[1]);
const prefixIdx = subject.indexOf(Session.PASEO_STASH_PREFIX);
const isPaseo = prefixIdx >= 0;
const branch = isPaseo
? subject.slice(prefixIdx + Session.PASEO_STASH_PREFIX.length).trim() || null
: null;
if (paseoOnly && !isPaseo) continue;
entries.push({ index, message: subject, branch, isPaseo });
}
this.emit({
type: "stash_list_response",
payload: { cwd, entries, error: null, requestId },
});
} catch (error) {
this.emit({
type: "stash_list_response",
payload: { cwd, entries: [], error: toCheckoutError(error), requestId },
});
}
}
private async handleCheckoutCommitRequest(
msg: Extract<SessionInboundMessage, { type: "checkout_commit_request" }>,
): Promise<void> {
@@ -5311,7 +5460,7 @@ export class Session {
if (agent.status === "error" || agent.attentionReason === "error") {
return "failed";
}
if (agent.status === "running" || agent.status === "initializing") {
if (agent.status === "running") {
return "running";
}
if (agent.requiresAttention) {
@@ -425,7 +425,7 @@ export class VoiceAssistantWebSocketServer {
!!requestHost &&
(origin === `http://${requestHost}` || origin === `https://${requestHost}`);
if (!origin || allowedOrigins.has(origin) || sameOrigin) {
if (!origin || allowedOrigins.has("*") || allowedOrigins.has(origin) || sameOrigin) {
callback(true);
} else {
this.incrementRuntimeCounter("originRejected");
+96
View File
@@ -1066,6 +1066,37 @@ export const ValidateBranchRequestSchema = z.object({
requestId: z.string(),
});
export const CheckoutSwitchBranchRequestSchema = z.object({
type: z.literal("checkout_switch_branch_request"),
cwd: z.string(),
branch: z.string(),
requestId: z.string(),
});
export const StashSaveRequestSchema = z.object({
type: z.literal("stash_save_request"),
cwd: z.string(),
/** Branch name to tag the stash with for later identification. */
branch: z.string().optional(),
requestId: z.string(),
});
export const StashPopRequestSchema = z.object({
type: z.literal("stash_pop_request"),
cwd: z.string(),
/** Zero-based index from stash_list_response. */
stashIndex: z.number().int().min(0),
requestId: z.string(),
});
export const StashListRequestSchema = z.object({
type: z.literal("stash_list_request"),
cwd: z.string(),
/** If true, only return paseo-created stashes. Default true. */
paseoOnly: z.boolean().optional(),
requestId: z.string(),
});
export const BranchSuggestionsRequestSchema = z.object({
type: z.literal("branch_suggestions_request"),
cwd: z.string(),
@@ -1436,6 +1467,10 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
CheckoutPushRequestSchema,
CheckoutPrCreateRequestSchema,
CheckoutPrStatusRequestSchema,
CheckoutSwitchBranchRequestSchema,
StashSaveRequestSchema,
StashPopRequestSchema,
StashListRequestSchema,
ValidateBranchRequestSchema,
BranchSuggestionsRequestSchema,
GitHubSearchRequestSchema,
@@ -2343,6 +2378,54 @@ export const CheckoutPrStatusResponseSchema = z.object({
}),
});
export const CheckoutSwitchBranchResponseSchema = z.object({
type: z.literal("checkout_switch_branch_response"),
payload: z.object({
cwd: z.string(),
success: z.boolean(),
branch: z.string(),
error: CheckoutErrorSchema.nullable(),
requestId: z.string(),
}),
});
const StashEntrySchema = z.object({
index: z.number().int().min(0),
message: z.string(),
branch: z.string().nullable(),
isPaseo: z.boolean(),
});
export const StashSaveResponseSchema = z.object({
type: z.literal("stash_save_response"),
payload: z.object({
cwd: z.string(),
success: z.boolean(),
error: CheckoutErrorSchema.nullable(),
requestId: z.string(),
}),
});
export const StashPopResponseSchema = z.object({
type: z.literal("stash_pop_response"),
payload: z.object({
cwd: z.string(),
success: z.boolean(),
error: CheckoutErrorSchema.nullable(),
requestId: z.string(),
}),
});
export const StashListResponseSchema = z.object({
type: z.literal("stash_list_response"),
payload: z.object({
cwd: z.string(),
entries: z.array(StashEntrySchema),
error: CheckoutErrorSchema.nullable(),
requestId: z.string(),
}),
});
export const ValidateBranchResponseSchema = z.object({
type: z.literal("validate_branch_response"),
payload: z.object({
@@ -2745,6 +2828,10 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
CheckoutPushResponseSchema,
CheckoutPrCreateResponseSchema,
CheckoutPrStatusResponseSchema,
CheckoutSwitchBranchResponseSchema,
StashSaveResponseSchema,
StashPopResponseSchema,
StashListResponseSchema,
ValidateBranchResponseSchema,
BranchSuggestionsResponseSchema,
GitHubSearchResponseSchema,
@@ -2973,6 +3060,15 @@ export type CheckoutPrCreateRequest = z.infer<typeof CheckoutPrCreateRequestSche
export type CheckoutPrCreateResponse = z.infer<typeof CheckoutPrCreateResponseSchema>;
export type CheckoutPrStatusRequest = z.infer<typeof CheckoutPrStatusRequestSchema>;
export type CheckoutPrStatusResponse = z.infer<typeof CheckoutPrStatusResponseSchema>;
export type CheckoutSwitchBranchRequest = z.infer<typeof CheckoutSwitchBranchRequestSchema>;
export type CheckoutSwitchBranchResponse = z.infer<typeof CheckoutSwitchBranchResponseSchema>;
export type StashSaveRequest = z.infer<typeof StashSaveRequestSchema>;
export type StashSaveResponse = z.infer<typeof StashSaveResponseSchema>;
export type StashPopRequest = z.infer<typeof StashPopRequestSchema>;
export type StashPopResponse = z.infer<typeof StashPopResponseSchema>;
export type StashListRequest = z.infer<typeof StashListRequestSchema>;
export type StashListResponse = z.infer<typeof StashListResponseSchema>;
export type StashEntry = z.infer<typeof StashEntrySchema>;
export type ValidateBranchRequest = z.infer<typeof ValidateBranchRequestSchema>;
export type ValidateBranchResponse = z.infer<typeof ValidateBranchResponseSchema>;
export type BranchSuggestionsRequest = z.infer<typeof BranchSuggestionsRequestSchema>;
+54 -46
View File
@@ -191,7 +191,6 @@ type CheckoutFileChange = {
isUntracked?: boolean;
};
type BranchSuggestionRefOrigin = "local" | "remote";
function normalizeBranchSuggestionName(raw: string): string | null {
const trimmed = raw.trim();
@@ -210,47 +209,57 @@ function normalizeBranchSuggestionName(raw: string): string | null {
normalized = normalized.slice("origin/".length);
}
if (!normalized || normalized === "HEAD") {
if (!normalized || normalized === "HEAD" || normalized === "origin") {
return null;
}
return normalized;
}
async function listGitRefs(cwd: string, refPrefix: string): Promise<string[]> {
const { stdout } = await execGit(`git for-each-ref --format="%(refname:short)" ${refPrefix}`, {
cwd,
env: READ_ONLY_GIT_ENV,
});
interface GitRef {
name: string;
committerDate: number;
}
async function listGitRefs(cwd: string, refPrefix: string): Promise<GitRef[]> {
const { stdout } = await execGit(
`git for-each-ref --sort=-committerdate --format="%(refname)%09%(committerdate:unix)" ${refPrefix}`,
{ cwd, env: READ_ONLY_GIT_ENV },
);
return stdout
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0);
.map((line) => {
const trimmed = line.trim();
if (!trimmed) return null;
const [name, dateStr] = trimmed.split("\t");
if (!name) return null;
return { name, committerDate: Number(dateStr) || 0 };
})
.filter((ref): ref is GitRef => ref !== null);
}
function sortBranchSuggestions(
branchNames: string[],
localBranchNames: Set<string>,
branchMeta: Map<string, { isLocal: boolean; committerDate: number }>,
query: string,
): string[] {
const normalizedQuery = query.trim().toLowerCase();
const hasQuery = normalizedQuery.length > 0;
return branchNames.sort((a, b) => {
const aLower = a.toLowerCase();
const bLower = b.toLowerCase();
if (hasQuery) {
const aPrefix = aLower.startsWith(normalizedQuery);
const bPrefix = bLower.startsWith(normalizedQuery);
const aPrefix = a.toLowerCase().startsWith(normalizedQuery);
const bPrefix = b.toLowerCase().startsWith(normalizedQuery);
if (aPrefix !== bPrefix) {
return aPrefix ? -1 : 1;
}
}
const aIsLocal = localBranchNames.has(a);
const bIsLocal = localBranchNames.has(b);
if (aIsLocal !== bIsLocal) {
return aIsLocal ? -1 : 1;
const aMeta = branchMeta.get(a);
const bMeta = branchMeta.get(b);
const aDate = aMeta?.committerDate ?? 0;
const bDate = bMeta?.committerDate ?? 0;
if (aDate !== bDate) {
return bDate - aDate;
}
return a.localeCompare(b);
@@ -272,41 +281,40 @@ export async function listBranchSuggestions(
listGitRefs(cwd, "refs/remotes/origin"),
]);
const merged = new Map<string, Set<BranchSuggestionRefOrigin>>();
for (const localRef of localRefs) {
const normalized = normalizeBranchSuggestionName(localRef);
if (!normalized) {
continue;
}
const origins = merged.get(normalized) ?? new Set<BranchSuggestionRefOrigin>();
origins.add("local");
merged.set(normalized, origins);
}
for (const remoteRef of remoteRefs) {
const normalized = normalizeBranchSuggestionName(remoteRef);
if (!normalized) {
continue;
}
const origins = merged.get(normalized) ?? new Set<BranchSuggestionRefOrigin>();
origins.add("remote");
merged.set(normalized, origins);
const branchMeta = new Map<string, { isLocal: boolean; committerDate: number }>();
for (const ref of localRefs) {
const normalized = normalizeBranchSuggestionName(ref.name);
if (!normalized) continue;
const existing = branchMeta.get(normalized);
branchMeta.set(normalized, {
isLocal: true,
committerDate: Math.max(ref.committerDate, existing?.committerDate ?? 0),
});
}
const filteredNames = Array.from(merged.keys()).filter((name) =>
for (const ref of remoteRefs) {
const normalized = normalizeBranchSuggestionName(ref.name);
if (!normalized) continue;
const existing = branchMeta.get(normalized);
if (!existing) {
branchMeta.set(normalized, { isLocal: false, committerDate: ref.committerDate });
} else {
branchMeta.set(normalized, {
...existing,
committerDate: Math.max(ref.committerDate, existing.committerDate),
});
}
}
const filteredNames = Array.from(branchMeta.keys()).filter((name) =>
query ? name.toLowerCase().includes(query) : true,
);
if (filteredNames.length === 0) {
return [];
}
const localBranchNames = new Set<string>();
for (const [name, origins] of merged) {
if (origins.has("local")) {
localBranchNames.add(name);
}
}
const ordered = sortBranchSuggestions(filteredNames, localBranchNames, query);
const ordered = sortBranchSuggestions(filteredNames, branchMeta, query);
return ordered.slice(0, limit);
}