fix(engine,dashboard): close 7 review findings on merger auto-sync
Data-loss fixes in syncWorktreeToHead:
- Untracked-restore checks `git ls-tree -r --name-only HEAD` to skip
paths the new tip added as tracked files; user bytes stay in the
stage dir instead of clobbering merged content.
- Apply-failure on a deleted/renamed file: conflictedFiles falls back
to parsing `diff --git a/<p> b/<p>` headers when --diff-filter=U
returns nothing.
- All git invocations pass `-c core.quotePath=false` so non-ASCII
paths round-trip through copyFileSync.
- Stash-and-ff re-verifies rev-parse HEAD === newSha right before
each `reset --hard HEAD` (TOCTOU). On mismatch we bail with patch
preserved on disk.
- Stage dir lifecycle moved into try/finally with preserveStageDir
flag — kept whenever the user's edits live only in patchPath; rm'd
on all clean exits.
- Patch written to disk before the apply attempt, not only on
failure, so a crash between snapshot and apply doesn't lose edits.
Multi-worktree-same-branch fix:
- New getRegisteredWorktreeBranches returns Array<{branch,path}>
instead of collapsing into a Map. Multiple worktrees can share a
branch via `git worktree add --force -b`; merger now syncs all of
them rather than silently skipping all but the last.
Contract + surfacing fixes:
- JSDoc on merge:auto-sync GitMutationType now lists the actually-
emitted outcome strings + stage enum.
- GET /api/tasks/merge-advance-events joins merge:auto-sync events
within ±5min of the advance and returns them in a new
`autoSync: AutoSyncOutcome[]` field; useMergeAdvanceNotice exposes
the same shape so the banner can surface pop-conflicts (including
patchPath) instead of dropping them.
Hygiene:
- Merger now reads the setting via normalizeMergeAdvanceAutoSyncMode
instead of an inline check + `as unknown` cast.
New tests:
- Untracked-collides-with-tracked preserves merged content.
- Apply failure on deleted file populates conflictedFiles from
patch header.
- Route surfaces autoSync outcomes (clean-sync + pop-conflict)
joined within the time window.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
32
.changeset/merger-auto-sync-review-fixes.md
Normal file
32
.changeset/merger-auto-sync-review-fixes.md
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
"@fusion/engine": patch
|
||||
"@fusion/dashboard": patch
|
||||
---
|
||||
|
||||
fix(engine,dashboard): close 7 code-review findings on the mergeAdvanceAutoSync hook
|
||||
|
||||
Tightens the freshly-landed merger auto-sync feature based on a structured code review.
|
||||
|
||||
**Data-loss fixes in `syncWorktreeToHead`:**
|
||||
- Untracked-file restore now compares against `git ls-tree -r --name-only HEAD` to detect when the new tip introduced a tracked file at the same path; collisions are reported in `untrackedSkippedAsTracked` and the user's bytes stay in the stage dir instead of clobbering the merged content.
|
||||
- When `git apply --3way` fails because a patched file was deleted/renamed at the new tip (`--diff-filter=U` returns nothing because nothing got staged), `conflictedFiles` falls back to parsing `diff --git a/<p> b/<p>` headers out of the captured patch — so the conflict surfaces with the right file names instead of `[]`.
|
||||
- `git ls-files` / `diff` calls now pass `-c core.quotePath=false` so paths with non-ASCII or special characters round-trip through `copyFileSync` instead of failing on backslash-escaped octal tokens.
|
||||
- The stash-and-ff path re-verifies `rev-parse HEAD === newSha` immediately before each destructive `reset --hard HEAD`; a concurrent merger advance now bails with `skipped-head-not-at-new-sha` (with the captured patch preserved on disk) instead of applying the patch against the wrong tree.
|
||||
- The stage dir is now tracked with a `preserveStageDir` flag in a `try/finally`: it is rm'd on all clean paths and on `skipped-head-not-at-new-sha` exits, but preserved whenever the user's edits live only in `patchPath` (pop-conflict, untracked-collides-with-tracked, reset failure, outer exception).
|
||||
- Patch is written to disk before the apply attempt, not only on failure, so a crash between snapshot and apply doesn't lose the user's edits.
|
||||
|
||||
**Multi-worktree-same-branch fix:**
|
||||
- New `getRegisteredWorktreeBranches` helper in `worktree-pool.ts` returns ALL `(branch, worktreePath)` entries rather than collapsing duplicates into a `Map<branch, path>`. Multiple worktrees can legitimately share a branch when the user created secondary checkouts via `git worktree add --force -b`; the merger now syncs every one of them instead of silently skipping all but the last.
|
||||
|
||||
**Contract + surfacing fixes:**
|
||||
- JSDoc on `merge:auto-sync` GitMutationType now documents the actually-emitted outcome strings (`clean-sync`, `synced-with-edits-restored`, `synced-with-pop-conflict`, `skipped-*`, `failed`, `enumeration-failed`, `exception`) and the actual `stage` enum, replacing the obsolete `smartPull`-shaped strings.
|
||||
- `GET /api/tasks/merge-advance-events` now joins `merge:auto-sync` events within a ±5min window of each advance and returns them in a new `autoSync: AutoSyncOutcome[]` field; `useMergeAdvanceNotice` exposes the same shape so the banner can surface pop-conflicts (including `patchPath` pointing at the user's saved edits) instead of leaving them in a black hole.
|
||||
|
||||
**Hygiene:**
|
||||
- Merger's setting read now uses `normalizeMergeAdvanceAutoSyncMode(settings.mergeAdvanceAutoSync)` (the exported normalizer) instead of an inline equality check + `as unknown` cast that bypassed type-checking.
|
||||
|
||||
**New backstop tests** in `merger-auto-sync.slow.test.ts`:
|
||||
- Untracked file colliding with a newly-tracked path is NOT overwritten and the merged content survives.
|
||||
- `git apply --3way` failure on a file deleted at the new tip populates `conflictedFiles` from the patch header.
|
||||
|
||||
**Route test** asserts `autoSync` outcomes are joined onto the matching advance event within the time window.
|
||||
@@ -2,6 +2,19 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ApiRequestError, api } from "../api";
|
||||
import { subscribeSse } from "../sse-bus";
|
||||
|
||||
export interface AutoSyncOutcome {
|
||||
worktreePath: string | null;
|
||||
outcome: string;
|
||||
mode: string;
|
||||
stashedFiles?: string[];
|
||||
untrackedRestored?: string[];
|
||||
untrackedSkippedAsTracked?: string[];
|
||||
conflictedFiles?: string[];
|
||||
patchPath?: string;
|
||||
stage?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface MergeAdvanceEvent {
|
||||
taskId: string;
|
||||
integrationBranch: string;
|
||||
@@ -16,6 +29,7 @@ interface MergeAdvanceEvent {
|
||||
dirty: boolean;
|
||||
untrackedCount: number;
|
||||
} | null;
|
||||
autoSync?: AutoSyncOutcome[];
|
||||
}
|
||||
|
||||
interface MergeAdvanceEventsResponse {
|
||||
|
||||
@@ -99,11 +99,86 @@ describe("merge advance events route", () => {
|
||||
dirty: true,
|
||||
untrackedCount: 2,
|
||||
},
|
||||
autoSync: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces merge:auto-sync outcomes (clean-sync + synced-with-pop-conflict) alongside the advance event", async () => {
|
||||
const advance = makeEvent({
|
||||
id: "evt-advance",
|
||||
mutationType: "merge:integration-ref-advance",
|
||||
timestamp: "2026-05-21T10:00:00.000Z",
|
||||
metadata: {
|
||||
integrationBranch: "main",
|
||||
refName: "refs/heads/main",
|
||||
toSha: "newSha",
|
||||
fromSha: "prevSha",
|
||||
advanceMode: "update-ref",
|
||||
succeeded: true,
|
||||
},
|
||||
});
|
||||
const clean = makeEvent({
|
||||
id: "evt-auto-clean",
|
||||
mutationType: "merge:auto-sync",
|
||||
timestamp: "2026-05-21T10:00:01.000Z",
|
||||
metadata: {
|
||||
worktreePath: "/repo",
|
||||
mode: "stash-and-ff",
|
||||
outcome: "clean-sync",
|
||||
integrationBranch: "main",
|
||||
},
|
||||
});
|
||||
const conflict = makeEvent({
|
||||
id: "evt-auto-conflict",
|
||||
mutationType: "merge:auto-sync",
|
||||
timestamp: "2026-05-21T10:00:02.000Z",
|
||||
metadata: {
|
||||
worktreePath: "/secondary",
|
||||
mode: "stash-and-ff",
|
||||
outcome: "synced-with-pop-conflict",
|
||||
integrationBranch: "main",
|
||||
conflictedFiles: ["packages/foo/old.ts"],
|
||||
patchPath: "/tmp/fusion-worktree-sync-abc/edits.patch",
|
||||
untrackedSkippedAsTracked: [],
|
||||
},
|
||||
});
|
||||
// Stale event outside the 5-minute window must be excluded.
|
||||
const stale = makeEvent({
|
||||
id: "evt-auto-stale",
|
||||
mutationType: "merge:auto-sync",
|
||||
timestamp: "2026-05-20T10:00:00.000Z",
|
||||
metadata: { worktreePath: "/old", mode: "stash-and-ff", outcome: "clean-sync" },
|
||||
});
|
||||
|
||||
const store: TaskStore = {
|
||||
getRootDir: vi.fn(() => process.cwd()),
|
||||
getRunAuditEvents: vi.fn((filters?: { mutationType?: string }) => {
|
||||
if (filters?.mutationType === "merge:integration-ref-advance") return [advance];
|
||||
if (filters?.mutationType === "merge:auto-sync") return [clean, conflict, stale];
|
||||
return [];
|
||||
}),
|
||||
} as unknown as TaskStore;
|
||||
|
||||
const app = express();
|
||||
app.use("/api", createApiRoutes(store));
|
||||
const res = await REQUEST(app, "GET", "/api/tasks/merge-advance-events");
|
||||
expect(res.status).toBe(200);
|
||||
const body = res.body as { events: Array<{ autoSync: Array<Record<string, unknown>> }> };
|
||||
expect(body.events).toHaveLength(1);
|
||||
expect(body.events[0].autoSync).toHaveLength(2);
|
||||
expect(body.events[0].autoSync).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ worktreePath: "/repo", outcome: "clean-sync" }),
|
||||
expect.objectContaining({
|
||||
worktreePath: "/secondary",
|
||||
outcome: "synced-with-pop-conflict",
|
||||
conflictedFiles: ["packages/foo/old.ts"],
|
||||
patchPath: "/tmp/fusion-worktree-sync-abc/edits.patch",
|
||||
}),
|
||||
]));
|
||||
});
|
||||
|
||||
it("maps succeeded false from metadata", async () => {
|
||||
const advance = makeEvent({
|
||||
id: "evt-advance-fail",
|
||||
|
||||
@@ -50,6 +50,19 @@ const REVIEW_VERDICT_RE = /###\s+Verdict:\s*(APPROVE|REVISE|RETHINK|UNAVAILABLE)
|
||||
const REVIEW_STEP_RE = /^(plan|code) review Step (\d+): (APPROVE|REVISE|RETHINK|UNAVAILABLE)\b/i;
|
||||
const DUPLICATE_STOPWORDS = new Set(["a", "an", "the", "and", "or", "of", "to", "for", "in", "is", "on", "with", "fn"]);
|
||||
|
||||
interface AutoSyncOutcome {
|
||||
worktreePath: string | null;
|
||||
outcome: string;
|
||||
mode: string;
|
||||
stashedFiles?: string[];
|
||||
untrackedRestored?: string[];
|
||||
untrackedSkippedAsTracked?: string[];
|
||||
conflictedFiles?: string[];
|
||||
patchPath?: string;
|
||||
stage?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface MergeAdvanceEvent {
|
||||
taskId: string;
|
||||
integrationBranch: string;
|
||||
@@ -64,6 +77,12 @@ interface MergeAdvanceEvent {
|
||||
dirty: boolean;
|
||||
untrackedCount: number;
|
||||
} | null;
|
||||
/** Per-worktree outcomes of the merger's post-advance auto-sync hook. Empty
|
||||
* array when `mergeAdvanceAutoSync: "off"` or no other worktree was on the
|
||||
* integration branch. A `synced-with-pop-conflict` entry carries
|
||||
* `patchPath` pointing at the user's saved edits and `conflictedFiles` /
|
||||
* `untrackedSkippedAsTracked` for surfacing in the conflict modal. */
|
||||
autoSync: AutoSyncOutcome[];
|
||||
}
|
||||
|
||||
interface MergeAdvanceEventsResponse {
|
||||
@@ -249,7 +268,39 @@ function extractUserCheckout(metadata: unknown): MergeAdvanceEvent["userCheckout
|
||||
};
|
||||
}
|
||||
|
||||
function extractMergeAdvanceEvent(event: RunAuditEvent): Omit<MergeAdvanceEvent, "userCheckout"> | null {
|
||||
function extractAutoSyncOutcome(event: RunAuditEvent): AutoSyncOutcome | null {
|
||||
const metadata = event.metadata;
|
||||
if (!metadata || typeof metadata !== "object") return null;
|
||||
const candidate = metadata as {
|
||||
worktreePath?: unknown;
|
||||
outcome?: unknown;
|
||||
mode?: unknown;
|
||||
stashedFiles?: unknown;
|
||||
untrackedRestored?: unknown;
|
||||
untrackedSkippedAsTracked?: unknown;
|
||||
conflictedFiles?: unknown;
|
||||
patchPath?: unknown;
|
||||
stage?: unknown;
|
||||
error?: unknown;
|
||||
};
|
||||
if (typeof candidate.outcome !== "string" || candidate.outcome.length === 0) return null;
|
||||
const stringArray = (v: unknown): string[] | undefined =>
|
||||
Array.isArray(v) && v.every((x) => typeof x === "string") ? (v as string[]) : undefined;
|
||||
return {
|
||||
worktreePath: typeof candidate.worktreePath === "string" ? candidate.worktreePath : null,
|
||||
outcome: candidate.outcome,
|
||||
mode: typeof candidate.mode === "string" ? candidate.mode : "stash-and-ff",
|
||||
stashedFiles: stringArray(candidate.stashedFiles),
|
||||
untrackedRestored: stringArray(candidate.untrackedRestored),
|
||||
untrackedSkippedAsTracked: stringArray(candidate.untrackedSkippedAsTracked),
|
||||
conflictedFiles: stringArray(candidate.conflictedFiles),
|
||||
patchPath: typeof candidate.patchPath === "string" ? candidate.patchPath : undefined,
|
||||
stage: typeof candidate.stage === "string" ? candidate.stage : undefined,
|
||||
error: typeof candidate.error === "string" ? candidate.error : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function extractMergeAdvanceEvent(event: RunAuditEvent): Omit<MergeAdvanceEvent, "userCheckout" | "autoSync"> | null {
|
||||
const metadata = event.metadata;
|
||||
if (!metadata || typeof metadata !== "object") {
|
||||
console.warn(`[merge-advance-events] dropping run-audit event ${event.id}: missing metadata`);
|
||||
@@ -555,9 +606,31 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
userCheckout = extractUserCheckout(matchingState.metadata);
|
||||
}
|
||||
|
||||
// Join in any per-worktree auto-sync outcomes for this task. We keep
|
||||
// events whose timestamp falls in a small window around the advance
|
||||
// so a `synced-with-pop-conflict` (carrying patchPath) surfaces to
|
||||
// the dashboard banner even when the sync ran slightly after the
|
||||
// advance event was recorded.
|
||||
const autoSyncEvents = storeWithRunAudit.getRunAuditEvents({
|
||||
taskId: extracted.taskId,
|
||||
domain: "git",
|
||||
mutationType: "merge:auto-sync",
|
||||
limit,
|
||||
});
|
||||
const advanceMs = Date.parse(advanceEvent.timestamp);
|
||||
const AUTO_SYNC_WINDOW_MS = 5 * 60 * 1000;
|
||||
const autoSync: AutoSyncOutcome[] = [];
|
||||
for (const ev of autoSyncEvents) {
|
||||
const evMs = Date.parse(ev.timestamp);
|
||||
if (Math.abs(evMs - advanceMs) > AUTO_SYNC_WINDOW_MS) continue;
|
||||
const outcome = extractAutoSyncOutcome(ev);
|
||||
if (outcome) autoSync.push(outcome);
|
||||
}
|
||||
|
||||
events.push({
|
||||
...extracted,
|
||||
userCheckout,
|
||||
autoSync,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -181,6 +181,101 @@ describe("runMergeAdvanceAutoSync (post-local-ref-advance reconciliation)", () =
|
||||
}
|
||||
});
|
||||
|
||||
it("untracked file colliding with a newly-tracked path is NOT overwritten — merged content is preserved", async () => {
|
||||
// User has an untracked `feature.txt` (locally meaningful) BEFORE the
|
||||
// task merge. The task's commit adds `feature.txt` as a tracked file with
|
||||
// different content. Without the collision guard, the auto-sync would
|
||||
// clobber the merged version with the user's stale untracked bytes.
|
||||
writeFileSync(join(fx.projectRoot, "feature.txt"), "USER stale local\n");
|
||||
|
||||
await runMergeAdvanceAutoSync({
|
||||
store: fx.store,
|
||||
audit: makeAudit(fx.store, "FN-COLLIDE"),
|
||||
taskId: "FN-COLLIDE",
|
||||
projectRootDir: fx.projectRoot,
|
||||
integrationBranch: "main",
|
||||
previousSha: fx.previousSha,
|
||||
newSha: fx.newSha,
|
||||
mode: "stash-and-ff",
|
||||
});
|
||||
|
||||
// The merged version (from the task commit) must win.
|
||||
expect(readFileSync(join(fx.projectRoot, "feature.txt"), "utf-8")).toBe("task work\n");
|
||||
|
||||
const autoSync = fx.recorded.filter((e) => e.mutationType === "merge:auto-sync");
|
||||
expect(autoSync).toHaveLength(1);
|
||||
// Auto-sync surfaces the collision as synced-with-pop-conflict so the
|
||||
// dashboard's existing conflict UI hooks fire.
|
||||
expect(autoSync[0].metadata).toMatchObject({
|
||||
outcome: "synced-with-pop-conflict",
|
||||
untrackedSkippedAsTracked: ["feature.txt"],
|
||||
});
|
||||
});
|
||||
|
||||
it("apply --3way failure on a deleted/renamed file: conflictedFiles populated from patch header, not left empty", async () => {
|
||||
// Set up a scenario where the user edits a tracked file that the merge
|
||||
// deletes. `git apply --3way` fails without staging unmerged entries, so
|
||||
// --diff-filter=U would otherwise return []. The patch-header fallback
|
||||
// must surface the affected path.
|
||||
//
|
||||
// Build fresh fixture: project has `doomed.txt` at previousSha, user
|
||||
// edits it, task commit deletes it.
|
||||
rmSync(fx.root, { recursive: true, force: true });
|
||||
const root = mkdtempSync(join(testTempParent(), "merger-auto-sync-delete-"));
|
||||
const upstream = join(root, "upstream.git");
|
||||
const projectRoot = join(root, "project");
|
||||
const taskWorktree = join(root, "task");
|
||||
git(root, `git init --bare -b main "${upstream}"`);
|
||||
git(root, `git clone "${upstream}" "${projectRoot}"`);
|
||||
git(projectRoot, 'git config user.email "u@e.com"');
|
||||
git(projectRoot, 'git config user.name "U"');
|
||||
writeFileSync(join(projectRoot, "doomed.txt"), "v1\n");
|
||||
git(projectRoot, "git add doomed.txt");
|
||||
git(projectRoot, 'git commit -m "init"');
|
||||
git(projectRoot, "git push -u origin main");
|
||||
const previousSha = git(projectRoot, "git rev-parse HEAD");
|
||||
|
||||
git(projectRoot, `git worktree add -b fusion/fn-test "${taskWorktree}"`);
|
||||
// Task commit deletes doomed.txt
|
||||
execSync(`rm "${join(taskWorktree, "doomed.txt")}"`);
|
||||
git(taskWorktree, "git add -A");
|
||||
git(taskWorktree, 'git commit -m "delete doomed"');
|
||||
const newSha = git(taskWorktree, "git rev-parse HEAD");
|
||||
git(projectRoot, `git update-ref refs/heads/main ${newSha}`);
|
||||
|
||||
// User modified doomed.txt before merge
|
||||
writeFileSync(join(projectRoot, "doomed.txt"), "v1\nuser edit\n");
|
||||
|
||||
const recorded: RunAuditEventInput[] = [];
|
||||
const store = {
|
||||
recordRunAuditEvent: vi.fn(async (input: RunAuditEventInput) => { recorded.push(input); }),
|
||||
} as unknown as TaskStore;
|
||||
|
||||
try {
|
||||
await runMergeAdvanceAutoSync({
|
||||
store,
|
||||
audit: makeAudit(store, "FN-DELETED"),
|
||||
taskId: "FN-DELETED",
|
||||
projectRootDir: projectRoot,
|
||||
integrationBranch: "main",
|
||||
previousSha,
|
||||
newSha,
|
||||
mode: "stash-and-ff",
|
||||
});
|
||||
|
||||
const autoSync = recorded.filter((e) => e.mutationType === "merge:auto-sync");
|
||||
expect(autoSync).toHaveLength(1);
|
||||
expect(autoSync[0].metadata).toMatchObject({ outcome: "synced-with-pop-conflict" });
|
||||
const metadata = autoSync[0].metadata as { conflictedFiles?: string[]; patchPath?: string };
|
||||
// Patch-header fallback must surface doomed.txt even though git apply
|
||||
// failed before staging any unmerged index entries.
|
||||
expect(metadata.conflictedFiles).toContain("doomed.txt");
|
||||
expect(typeof metadata.patchPath).toBe("string");
|
||||
} finally {
|
||||
try { rmSync(root, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
}
|
||||
});
|
||||
|
||||
it("no other worktrees on integration branch → no audit emissions", async () => {
|
||||
await runMergeAdvanceAutoSync({
|
||||
store: fx.store,
|
||||
|
||||
@@ -83,13 +83,14 @@ import {
|
||||
type TaskSourceIssue,
|
||||
type Task,
|
||||
type AutostashOrphanRecord,
|
||||
normalizeMergeAdvanceAutoSyncMode,
|
||||
} from "@fusion/core";
|
||||
import { describeModel, promptWithFallback } from "./pi.js";
|
||||
import { accumulateSessionTokenUsage } from "./session-token-usage.js";
|
||||
import { createResolvedAgentSession, extractRuntimeHint, resolveMergerSessionModel } from "./agent-session-helpers.js";
|
||||
import { createFallbackModelObserver } from "./fallback-model-observer.js";
|
||||
import { buildSessionSkillContext } from "./session-skill-context.js";
|
||||
import { classifyTaskWorktree, getRegisteredWorktreeBranchMap, RemovalReason, removeWorktree, type WorktreePool } from "./worktree-pool.js";
|
||||
import { classifyTaskWorktree, getRegisteredWorktreeBranches, RemovalReason, removeWorktree, type WorktreePool } from "./worktree-pool.js";
|
||||
import { activeSessionRegistry } from "./active-session-registry.js";
|
||||
import { AgentLogger } from "./agent-logger.js";
|
||||
import { mergerLog } from "./logger.js";
|
||||
@@ -158,9 +159,14 @@ async function runMergeAdvanceAutoSync(input: {
|
||||
mode: "ff-only" | "stash-and-ff";
|
||||
}): Promise<void> {
|
||||
const { audit, taskId, projectRootDir, integrationBranch, previousSha, newSha, mode } = input;
|
||||
let branchMap: Map<string, string>;
|
||||
// `getRegisteredWorktreeBranches` returns ALL (branch, path) pairs, not a
|
||||
// Map keyed by branch — multiple worktrees can share a branch when the user
|
||||
// created secondary checkouts via `git worktree add --force -b`. Collapsing
|
||||
// to a Map would silently skip all but the last-iterated of those, which is
|
||||
// exactly the surprise-`git status` bug this hook was meant to fix.
|
||||
let entries: Array<{ branch: string; worktreePath: string }>;
|
||||
try {
|
||||
branchMap = await getRegisteredWorktreeBranchMap(projectRootDir);
|
||||
entries = await getRegisteredWorktreeBranches(projectRootDir);
|
||||
} catch (err: unknown) {
|
||||
await audit.git({
|
||||
type: "merge:auto-sync",
|
||||
@@ -177,9 +183,9 @@ async function runMergeAdvanceAutoSync(input: {
|
||||
}
|
||||
|
||||
const matchingWorktrees: string[] = [];
|
||||
for (const [branch, worktreePath] of branchMap.entries()) {
|
||||
if (branch === integrationBranch) {
|
||||
matchingWorktrees.push(worktreePath);
|
||||
for (const entry of entries) {
|
||||
if (entry.branch === integrationBranch) {
|
||||
matchingWorktrees.push(entry.worktreePath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,10 +244,10 @@ async function runMergeAdvanceAutoSync(input: {
|
||||
worktreePath,
|
||||
outcome: result.kind,
|
||||
...(result.kind === "synced-with-pop-conflict"
|
||||
? { conflictedFiles: result.conflictedFiles, patchPath: result.patchPath, stashedFiles: result.stashedFiles }
|
||||
? { conflictedFiles: result.conflictedFiles, patchPath: result.patchPath, stashedFiles: result.stashedFiles, untrackedSkippedAsTracked: result.untrackedSkippedAsTracked }
|
||||
: {}),
|
||||
...(result.kind === "synced-with-edits-restored"
|
||||
? { stashedFiles: result.stashedFiles, untrackedRestored: result.untrackedRestored }
|
||||
? { stashedFiles: result.stashedFiles, untrackedRestored: result.untrackedRestored, untrackedSkippedAsTracked: result.untrackedSkippedAsTracked }
|
||||
: {}),
|
||||
...(result.kind === "failed"
|
||||
? { stage: result.stage, error: result.error }
|
||||
@@ -9726,10 +9732,7 @@ export async function aiMergeTask(
|
||||
// the merge has already landed at this point: failing the merger run
|
||||
// because a downstream worktree sync threw would leave the project in
|
||||
// a worse state than just emitting the failure as an audit event.
|
||||
const autoSyncSetting = (settings as { mergeAdvanceAutoSync?: unknown }).mergeAdvanceAutoSync;
|
||||
const autoSyncMode = autoSyncSetting === "off" || autoSyncSetting === "ff-only" || autoSyncSetting === "stash-and-ff"
|
||||
? autoSyncSetting
|
||||
: "stash-and-ff";
|
||||
const autoSyncMode = normalizeMergeAdvanceAutoSyncMode(settings.mergeAdvanceAutoSync);
|
||||
if (autoSyncMode !== "off") {
|
||||
try {
|
||||
await runMergeAdvanceAutoSync({
|
||||
|
||||
@@ -238,21 +238,29 @@ export type GitMutationType =
|
||||
* newSha?: string;
|
||||
* worktreePath?: string;
|
||||
* outcome:
|
||||
* | "clean-pull"
|
||||
* | "stash-pull-pop"
|
||||
* | "stash-pop-conflict"
|
||||
* | "skipped-dirty"
|
||||
* | "skipped-not-on-branch"
|
||||
* | "failed"
|
||||
* | "enumeration-failed"
|
||||
* | "exception";
|
||||
* stashSha?: string;
|
||||
* stashLabel?: string;
|
||||
* conflictedFiles?: string[];
|
||||
* stage?: "stash" | "pull" | "pop";
|
||||
* | "clean-sync" // worktree was clean against previousSha; reset --hard HEAD snapped it forward
|
||||
* | "synced-with-edits-restored" // real edits captured as patch, snapped to HEAD, patch re-applied cleanly
|
||||
* | "synced-with-pop-conflict" // patch failed to reapply OR untracked file collided with newly-tracked path
|
||||
* | "skipped-dirty" // ff-only mode + real edits → no-op (banner surfaces for manual handling)
|
||||
* | "skipped-not-on-branch" // worktree's HEAD is on a different branch than integrationBranch
|
||||
* | "skipped-head-not-at-new-sha" // concurrent advance moved HEAD past newSha between guard and reset
|
||||
* | "failed" // git command exited non-zero; see stage + error
|
||||
* | "enumeration-failed" // `git worktree list --porcelain` failed in the project root
|
||||
* | "exception"; // syncWorktreeToHead threw outside its own try/catch
|
||||
* stashedFiles?: string[]; // tracked-file edits captured into patchPath
|
||||
* patchPath?: string; // /tmp/fusion-worktree-sync-<id>/edits.patch (preserved when outcome surfaces a conflict)
|
||||
* conflictedFiles?: string[]; // paths git apply --3way couldn't reconcile; falls back to patch-header parsing when the index has no unmerged entries
|
||||
* untrackedRestored?: string[]; // untracked files copied back into the worktree after the snap
|
||||
* untrackedSkippedAsTracked?: string[]; // untracked files whose paths collided with newly-tracked files at HEAD; left in the stage dir
|
||||
* stage?: "snapshot" | "reset" | "apply" | "untracked-restore"; // only on outcome === "failed"
|
||||
* error?: string;
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* Per-step `pull:fast-forward`, `stash:push`, `stash:pop`, and
|
||||
* `stash:pop-conflict` events that flow through the merger's auditor as
|
||||
* part of this auto-sync carry `metadata.autoSync = true` so consumers can
|
||||
* filter them apart from user-triggered git operations.
|
||||
*/
|
||||
| "merge:auto-sync"
|
||||
/**
|
||||
|
||||
@@ -128,8 +128,25 @@ export async function getRegisteredWorktreePaths(rootDir: string): Promise<Set<s
|
||||
}
|
||||
|
||||
export async function getRegisteredWorktreeBranchMap(rootDir: string): Promise<Map<string, string>> {
|
||||
const { rawOutput } = await describeRegisteredWorktrees(rootDir);
|
||||
const branchMap = new Map<string, string>();
|
||||
for (const entry of await getRegisteredWorktreeBranches(rootDir)) {
|
||||
branchMap.set(entry.branch, entry.worktreePath);
|
||||
}
|
||||
return branchMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same source as `getRegisteredWorktreeBranchMap` but returns ALL
|
||||
* (branch, worktreePath) pairs rather than collapsing duplicates by branch.
|
||||
* Multiple worktrees can legitimately share a branch when the user has
|
||||
* created secondary checkouts via `git worktree add --force -b <branch>`;
|
||||
* callers that need to act on every such worktree (e.g. the merger's
|
||||
* post-advance auto-sync) must use this array form to avoid silently
|
||||
* skipping all but the last-iterated checkout.
|
||||
*/
|
||||
export async function getRegisteredWorktreeBranches(rootDir: string): Promise<Array<{ branch: string; worktreePath: string }>> {
|
||||
const { rawOutput } = await describeRegisteredWorktrees(rootDir);
|
||||
const entries: Array<{ branch: string; worktreePath: string }> = [];
|
||||
let currentWorktree: string | null = null;
|
||||
|
||||
for (const line of rawOutput.split("\n")) {
|
||||
@@ -144,12 +161,12 @@ export async function getRegisteredWorktreeBranchMap(rootDir: string): Promise<M
|
||||
? branchRef.slice("refs/heads/".length)
|
||||
: branchRef;
|
||||
if (branchName) {
|
||||
branchMap.set(branchName, currentWorktree);
|
||||
entries.push({ branch: branchName, worktreePath: currentWorktree });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return branchMap;
|
||||
return entries;
|
||||
}
|
||||
|
||||
export async function isRegisteredGitWorktree(rootDir: string, worktreePath: string): Promise<boolean> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { mkdtempSync, copyFileSync, mkdirSync, readFileSync, rmSync, existsSync } from "node:fs";
|
||||
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
@@ -27,15 +27,21 @@ export interface SyncWorktreeInput {
|
||||
|
||||
export type SyncWorktreeResult =
|
||||
| { kind: "clean-sync"; fromSha: string; toSha: string }
|
||||
| { kind: "synced-with-edits-restored"; fromSha: string; toSha: string; stashedFiles: string[]; untrackedRestored: string[] }
|
||||
| { kind: "synced-with-pop-conflict"; fromSha: string; toSha: string; stashedFiles: string[]; conflictedFiles: string[]; patchPath: string }
|
||||
| { kind: "synced-with-edits-restored"; fromSha: string; toSha: string; stashedFiles: string[]; untrackedRestored: string[]; untrackedSkippedAsTracked: string[] }
|
||||
| { kind: "synced-with-pop-conflict"; fromSha: string; toSha: string; stashedFiles: string[]; conflictedFiles: string[]; patchPath: string; untrackedSkippedAsTracked: string[] }
|
||||
| { kind: "skipped-dirty"; fromSha: string; reason: "ff-only-mode-requires-clean-tree"; dirtyFiles: string[]; untrackedFiles: string[] }
|
||||
| { kind: "skipped-not-on-branch"; currentBranch: string }
|
||||
| { kind: "skipped-head-not-at-new-sha"; currentSha: string; expectedNewSha: string }
|
||||
| { kind: "failed"; stage: "snapshot" | "reset" | "apply" | "untracked-restore"; error: string };
|
||||
|
||||
/**
|
||||
* Run a git command with `core.quotePath=false` always set so path-listing
|
||||
* commands (`ls-files`, `diff --name-only`, patch headers from `diff
|
||||
* --binary`) emit raw UTF-8 paths instead of backslash-escaped octal — required
|
||||
* for round-tripping non-ASCII filenames through copyFileSync / fs paths.
|
||||
*/
|
||||
async function runGit(args: string[], cwd: string, timeoutMs: number): Promise<{ stdout: string; stderr: string }> {
|
||||
const result = await execFileAsync("git", args, {
|
||||
const result = await execFileAsync("git", ["-c", "core.quotePath=false", ...args], {
|
||||
cwd,
|
||||
timeout: timeoutMs,
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
@@ -68,6 +74,21 @@ async function listFiles(cwd: string, args: string[]): Promise<string[]> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `diff --git a/<path> b/<path>` headers out of a patch produced with
|
||||
* `git diff --binary`. Used as a fallback for populating `conflictedFiles`
|
||||
* when `git apply --3way` fails without staging unmerged index entries — e.g.
|
||||
* the patch references a file deleted or renamed at the new tip.
|
||||
*/
|
||||
function extractFilesFromPatch(patch: string): string[] {
|
||||
const out = new Set<string>();
|
||||
for (const line of patch.split("\n")) {
|
||||
const m = /^diff --git a\/(.+) b\/(.+)$/.exec(line);
|
||||
if (m) out.add(m[2]);
|
||||
}
|
||||
return [...out];
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring a worktree's index + files forward to its current HEAD after the
|
||||
* integration-branch ref was advanced *locally* (typically by the merger via
|
||||
@@ -84,7 +105,14 @@ async function listFiles(cwd: string, args: string[]): Promise<string[]> {
|
||||
* against `previousSha`, copy untracked files to a temp dir, snap to
|
||||
* HEAD, then reapply (`git apply --3way`) and restore untracked. Patch
|
||||
* conflicts surface as `synced-with-pop-conflict` and the patch is left
|
||||
* on disk for manual recovery.
|
||||
* on disk for manual recovery. Untracked files whose paths collide with
|
||||
* newly-tracked files at HEAD are NOT overwritten — they are reported in
|
||||
* `untrackedSkippedAsTracked` and remain in the temp dir.
|
||||
*
|
||||
* The stash-and-ff path re-verifies `rev-parse HEAD === newSha` immediately
|
||||
* before the destructive `reset --hard HEAD` so that a concurrent merger
|
||||
* advance (which would move HEAD past `newSha` between snapshot and reset)
|
||||
* can't trick us into applying the patch against the wrong tree.
|
||||
*
|
||||
* In `ff-only` mode any real edits cause the function to bail with
|
||||
* `skipped-dirty`; the caller is expected to surface the Merge Advance Notice
|
||||
@@ -108,13 +136,10 @@ export async function syncWorktreeToHead(input: SyncWorktreeInput): Promise<Sync
|
||||
}
|
||||
const headSha = (await runGit(["rev-parse", "HEAD"], worktreePath, 5_000)).stdout.trim();
|
||||
if (headSha !== newSha) {
|
||||
// The ref already moved past `newSha` (or hasn't reached it). Bail rather
|
||||
// than risk a partial reconciliation against a moving target.
|
||||
return { kind: "skipped-head-not-at-new-sha", currentSha: headSha, expectedNewSha: newSha };
|
||||
}
|
||||
|
||||
// Snapshot real edits against `previousSha` (which is the tree the worktree
|
||||
// *should* currently match if no one touched it after the ref advance).
|
||||
// Snapshot real edits against `previousSha`.
|
||||
let dirtyFiles: string[];
|
||||
let untrackedFiles: string[];
|
||||
try {
|
||||
@@ -126,6 +151,12 @@ export async function syncWorktreeToHead(input: SyncWorktreeInput): Promise<Sync
|
||||
const hasRealEdits = dirtyFiles.length > 0 || untrackedFiles.length > 0;
|
||||
|
||||
if (!hasRealEdits) {
|
||||
// Re-check HEAD right before destructive reset (TOCTOU: another merger
|
||||
// could have advanced past newSha while we were enumerating).
|
||||
const headSha2 = (await runGit(["rev-parse", "HEAD"], worktreePath, 5_000)).stdout.trim();
|
||||
if (headSha2 !== newSha) {
|
||||
return { kind: "skipped-head-not-at-new-sha", currentSha: headSha2, expectedNewSha: newSha };
|
||||
}
|
||||
try {
|
||||
await runGit(["reset", "--hard", "HEAD"], worktreePath, 30_000);
|
||||
} catch (err: unknown) {
|
||||
@@ -142,29 +173,43 @@ export async function syncWorktreeToHead(input: SyncWorktreeInput): Promise<Sync
|
||||
return { kind: "skipped-dirty", fromSha: previousSha, reason: "ff-only-mode-requires-clean-tree", dirtyFiles, untrackedFiles };
|
||||
}
|
||||
|
||||
// stash-and-ff: snapshot real edits + untracked, snap, restore.
|
||||
// ── stash-and-ff path ───────────────────────────────────────────────────
|
||||
const stageDir = mkdtempSync(join(tmpdir(), "fusion-worktree-sync-"));
|
||||
const patchPath = join(stageDir, "edits.patch");
|
||||
const untrackedDir = join(stageDir, "untracked");
|
||||
let preserveStageDir = false;
|
||||
try {
|
||||
mkdirSync(untrackedDir, { recursive: true });
|
||||
|
||||
// 1. Diff against previousSha (binary, full-file) captures only real edits.
|
||||
// 1. Capture real edits as a binary patch against previousSha.
|
||||
let patch = "";
|
||||
if (dirtyFiles.length > 0) {
|
||||
const { stdout } = await runGit(["diff", "--binary", "--no-color", previousSha], worktreePath, 60_000);
|
||||
patch = stdout;
|
||||
try {
|
||||
if (dirtyFiles.length > 0) {
|
||||
const { stdout } = await runGit(["diff", "--binary", "--no-color", previousSha], worktreePath, 60_000);
|
||||
patch = stdout;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
return { kind: "failed", stage: "snapshot", error: commandError(err) };
|
||||
}
|
||||
|
||||
// 2. Save untracked files.
|
||||
// 2. Save untracked files to the stage dir. Persist the patch alongside
|
||||
// them now (not only on conflict) so a crash between this point and
|
||||
// `git apply` doesn't lose the user's edits.
|
||||
if (patch.length > 0) {
|
||||
try {
|
||||
writeFileSync(patchPath, patch);
|
||||
} catch {
|
||||
// best-effort; patch still lives in memory for the apply attempt
|
||||
}
|
||||
}
|
||||
for (const rel of untrackedFiles) {
|
||||
const src = join(worktreePath, rel);
|
||||
const dst = join(untrackedDir, rel);
|
||||
mkdirSync(dirname(dst), { recursive: true });
|
||||
try {
|
||||
mkdirSync(dirname(dst), { recursive: true });
|
||||
copyFileSync(src, dst);
|
||||
} catch {
|
||||
// best-effort; missing entries skipped
|
||||
// best-effort; missing / unreadable entries skipped
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,10 +225,18 @@ export async function syncWorktreeToHead(input: SyncWorktreeInput): Promise<Sync
|
||||
},
|
||||
});
|
||||
|
||||
// 3. Snap worktree+index to HEAD (NEW).
|
||||
// 3. Re-check HEAD immediately before destructive reset — a concurrent
|
||||
// merger could have advanced past newSha while we were snapshotting
|
||||
// (`git diff --binary` can take seconds on large patches).
|
||||
const headSha3 = (await runGit(["rev-parse", "HEAD"], worktreePath, 5_000)).stdout.trim();
|
||||
if (headSha3 !== newSha) {
|
||||
preserveStageDir = true; // patch + untracked saved; user can recover by hand
|
||||
return { kind: "skipped-head-not-at-new-sha", currentSha: headSha3, expectedNewSha: newSha };
|
||||
}
|
||||
try {
|
||||
await runGit(["reset", "--hard", "HEAD"], worktreePath, 30_000);
|
||||
} catch (err: unknown) {
|
||||
preserveStageDir = true; // patch saved on disk for manual recovery
|
||||
return { kind: "failed", stage: "reset", error: commandError(err) };
|
||||
}
|
||||
await emitSafe({
|
||||
@@ -191,9 +244,10 @@ export async function syncWorktreeToHead(input: SyncWorktreeInput): Promise<Sync
|
||||
metadata: { taskId, worktreePath, integrationBranch, fromSha: previousSha, toSha: newSha, succeeded: true, kind: "snap-after-snapshot" },
|
||||
});
|
||||
|
||||
// 4. Reapply patch.
|
||||
// 4. Reapply the captured patch via `git apply --3way`.
|
||||
let popConflict = false;
|
||||
const conflictedFiles: string[] = [];
|
||||
let conflictedFiles: string[] = [];
|
||||
let applyError: string | undefined;
|
||||
if (patch.length > 0) {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
@@ -209,40 +263,40 @@ export async function syncWorktreeToHead(input: SyncWorktreeInput): Promise<Sync
|
||||
child.stdin.end();
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
// Persist patch for manual recovery and surface a structured conflict.
|
||||
try {
|
||||
await import("node:fs").then((fs) => fs.writeFileSync(patchPath, patch));
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
popConflict = true;
|
||||
const conflicts = await listFiles(worktreePath, ["diff", "--name-only", "--diff-filter=U"]);
|
||||
for (const c of conflicts) conflictedFiles.push(c);
|
||||
await emitSafe({
|
||||
mutationType: "stash:pop-conflict",
|
||||
metadata: {
|
||||
taskId,
|
||||
worktreePath,
|
||||
patchPath,
|
||||
conflictedFiles,
|
||||
kind: "patch-apply-conflict",
|
||||
error: commandError(err),
|
||||
advice: `Real edits were saved to ${patchPath}. Apply manually with \`git apply --3way ${patchPath}\` after resolving conflicts.`,
|
||||
},
|
||||
});
|
||||
applyError = commandError(err);
|
||||
// Index-staged unmerged paths take priority; fall back to patch-header
|
||||
// parsing when git apply failed too early to stage anything (e.g. the
|
||||
// patch referenced a file deleted or renamed at the new tip).
|
||||
const stagedConflicts = await listFiles(worktreePath, ["diff", "--name-only", "--diff-filter=U"]);
|
||||
conflictedFiles = stagedConflicts.length > 0 ? stagedConflicts : extractFilesFromPatch(patch);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Restore untracked files.
|
||||
// 5. Restore untracked files — but NEVER overwrite a path that is now
|
||||
// tracked at HEAD (the new tip may have added that path as a tracked
|
||||
// file; clobbering it with the user's stale untracked bytes silently
|
||||
// erases the merge content). Note: `reset --hard HEAD` does not touch
|
||||
// untracked files, so `existsSync(dst)` is true for paths that the
|
||||
// user already had on disk — only the tracked-at-HEAD check
|
||||
// distinguishes a genuine collision from a survivor.
|
||||
const trackedAtHead = new Set(await listFiles(worktreePath, ["ls-tree", "-r", "--name-only", "HEAD"]));
|
||||
const restored: string[] = [];
|
||||
const untrackedSkippedAsTracked: string[] = [];
|
||||
for (const rel of untrackedFiles) {
|
||||
const src = join(untrackedDir, rel);
|
||||
const dst = join(worktreePath, rel);
|
||||
if (!existsSync(src)) continue;
|
||||
if (trackedAtHead.has(rel)) {
|
||||
// NEW introduced a tracked file at this path. Don't clobber it; user's
|
||||
// bytes remain in stageDir/untracked/ for manual recovery.
|
||||
untrackedSkippedAsTracked.push(rel);
|
||||
continue;
|
||||
}
|
||||
const dst = join(worktreePath, rel);
|
||||
try {
|
||||
mkdirSync(dirname(dst), { recursive: true });
|
||||
const data = readFileSync(src);
|
||||
await import("node:fs").then((fs) => fs.writeFileSync(dst, data));
|
||||
writeFileSync(dst, data);
|
||||
restored.push(rel);
|
||||
} catch {
|
||||
// best-effort
|
||||
@@ -250,22 +304,59 @@ export async function syncWorktreeToHead(input: SyncWorktreeInput): Promise<Sync
|
||||
}
|
||||
|
||||
if (popConflict) {
|
||||
// Keep stageDir so the patch survives for manual recovery.
|
||||
return { kind: "synced-with-pop-conflict", fromSha: previousSha, toSha: newSha, stashedFiles: dirtyFiles, conflictedFiles, patchPath };
|
||||
preserveStageDir = true;
|
||||
await emitSafe({
|
||||
mutationType: "stash:pop-conflict",
|
||||
metadata: {
|
||||
taskId,
|
||||
worktreePath,
|
||||
patchPath,
|
||||
conflictedFiles,
|
||||
untrackedSkippedAsTracked,
|
||||
kind: "patch-apply-conflict",
|
||||
error: applyError,
|
||||
advice: `Real edits were saved to ${patchPath}. Apply manually with \`git apply --3way ${patchPath}\` after resolving conflicts.`,
|
||||
},
|
||||
});
|
||||
return { kind: "synced-with-pop-conflict", fromSha: previousSha, toSha: newSha, stashedFiles: dirtyFiles, conflictedFiles, patchPath, untrackedSkippedAsTracked };
|
||||
}
|
||||
|
||||
if (untrackedSkippedAsTracked.length > 0) {
|
||||
// Patch applied cleanly but at least one untracked file collided with a
|
||||
// newly-tracked path. Preserve the stage dir so the user can recover
|
||||
// those bytes — and surface a stash:pop-conflict so the dashboard's
|
||||
// existing conflict UI hooks fire.
|
||||
preserveStageDir = true;
|
||||
await emitSafe({
|
||||
mutationType: "stash:pop-conflict",
|
||||
metadata: {
|
||||
taskId,
|
||||
worktreePath,
|
||||
patchPath,
|
||||
conflictedFiles: untrackedSkippedAsTracked,
|
||||
untrackedSkippedAsTracked,
|
||||
kind: "untracked-collides-with-tracked",
|
||||
advice: `Saved local copies of ${untrackedSkippedAsTracked.length} untracked file(s) that collide with newly-tracked paths at ${stageDir}/untracked/. Compare against the worktree before deleting.`,
|
||||
},
|
||||
});
|
||||
return { kind: "synced-with-pop-conflict", fromSha: previousSha, toSha: newSha, stashedFiles: dirtyFiles, conflictedFiles: untrackedSkippedAsTracked, patchPath, untrackedSkippedAsTracked };
|
||||
}
|
||||
|
||||
// Clean: emit stash:pop and clean up the stage dir.
|
||||
await emitSafe({
|
||||
mutationType: "stash:pop",
|
||||
metadata: { taskId, worktreePath, stashedFiles: dirtyFiles, untrackedRestored: restored, kind: "patch-applied" },
|
||||
});
|
||||
try {
|
||||
rmSync(stageDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
return { kind: "synced-with-edits-restored", fromSha: previousSha, toSha: newSha, stashedFiles: dirtyFiles, untrackedRestored: restored };
|
||||
return { kind: "synced-with-edits-restored", fromSha: previousSha, toSha: newSha, stashedFiles: dirtyFiles, untrackedRestored: restored, untrackedSkippedAsTracked };
|
||||
} catch (err: unknown) {
|
||||
preserveStageDir = true; // patch already on disk; keep it for the user
|
||||
return { kind: "failed", stage: "apply", error: commandError(err) };
|
||||
} finally {
|
||||
if (!preserveStageDir) {
|
||||
try {
|
||||
rmSync(stageDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user