fix(dashboard): pull syncs the worktree to local integration tip, not just to origin

When the merger advances local `refs/heads/<integrationBranch>` via
`update-ref` without pushing, the user's project-root worktree HEAD
(symbolic to that branch) follows immediately to the new sha, but the
working files and index don't. The integration-mode pull only ran
`git merge --ff-only origin/<branch>`, which short-circuits as
"already up to date" when local is ahead of origin — leaving the
worktree visibly stale even though "Pull completed" was reported.

Pull now explicitly `git reset --hard <localIntegrationTip>` after
the origin fast-forward step. The autostash above protects user edits,
so the reset is safe regardless of whether the origin FF ran.

Regression test in routes-git.test.ts simulates the
local-ahead-of-origin scenario and asserts the reset-to-local-tip is
issued.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-23 18:23:24 -07:00
parent 556fd313be
commit de67c5137f
3 changed files with 73 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
---
"@fusion/dashboard": patch
---
fix(dashboard): pull syncs the worktree to local integration tip, not just to origin
The integration-mode `POST /api/git/pull` (used by the merge-advance-notice banner) only ran `git merge --ff-only origin/<branch>` after fetching. When the merger had advanced local `refs/heads/<integrationBranch>` via `update-ref` but the user hadn't pushed yet, the worktree's HEAD already resolved to the new sha (symbolic ref follow) but the working tree and index were still at the old state. The fast-forward step short-circuited (`already up to date with origin`) and the user saw "Pull completed" with `fromSha === toSha` while their files visibly stayed behind.
Pull now explicitly resets the worktree to `refs/heads/<integrationBranch>` after the origin fast-forward step. The autostash above protects user edits, so the reset is safe regardless of whether the origin FF ran.

View File

@@ -1049,6 +1049,39 @@ describe("Git Management endpoints", () => {
expect(store.recordRunAuditEvent).not.toHaveBeenCalled();
});
it("syncs working tree to local integration tip when origin is behind (local-ahead-of-origin case)", async () => {
// Simulate the merger-update-ref scenario: HEAD is symbolic to
// refs/heads/integration, the merger advanced the ref to a new sha,
// and origin doesn't have it yet (tryFastForwardFromOrigin returns
// no-op). The pull must still sync the worktree to the new local tip.
const issuedCommands: string[] = [];
runGitSpy.mockImplementation((async (args: string[]) => {
const cmd = args.join(" ");
issuedCommands.push(cmd);
if (cmd.startsWith("worktree list --porcelain")) return "worktree /repo\n";
if (cmd.startsWith("rev-parse --git-dir")) return ".git\n";
if (cmd.startsWith("rev-parse --abbrev-ref HEAD")) return "integration\n";
if (cmd === "rev-parse --verify refs/heads/integration") return "newtip0000\n";
if (cmd.startsWith("rev-parse HEAD")) return "newtip0000\n";
return "";
}) as typeof resolveDiffBaseModule.runGitCommand);
const { app, store } = buildIntegrationApp();
const res = await REQUEST(app, "POST", "/api/git/pull", JSON.stringify({ worktreePath: "/repo", integrationBranch: "integration", taskId: "FN-5419" }), { "Content-Type": "application/json" });
expect(res.status).toBe(200);
expect(res.body.kind).toBe("pull-clean");
// The fix: a `reset --hard <localTip>` must have been issued so the
// worktree advances to the merger-updated ref, not just left at
// whatever HEAD symbolically resolved to.
expect(issuedCommands).toContain("reset --hard newtip0000");
// tryFastForwardFromOrigin still ran (origin sync is still attempted).
expect(vi.mocked(engineModule.tryFastForwardFromOrigin)).toHaveBeenCalled();
expect(store.recordRunAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({ mutationType: "pull:fast-forward", taskId: "FN-5419" }),
);
});
it("supports stash-resolve, stash-drop, and stash-apply", async () => {
vi.mocked(engineModule.getConflictedFiles).mockResolvedValueOnce(["src/file.ts"]).mockResolvedValueOnce([]).mockResolvedValueOnce([]).mockResolvedValueOnce(["src/file.ts"]);
const { app, store } = buildIntegrationApp();

View File

@@ -1300,6 +1300,37 @@ export async function pullGitBranch(cwd?: string, options?: PullGitBranchOptions
const pullStart = performance.now();
await tryFastForwardFromOrigin(rootDir, taskId, integration.integrationBranch, integration.integrationRemote ?? "origin");
// Sync working tree + index to the local integration tip. The merger
// advances `refs/heads/<integrationBranch>` via `git update-ref` without
// touching any worktree. When HEAD here is symbolic to that branch
// (the normal case in the user's project-root checkout), HEAD already
// resolves to the new sha — but the working files and index don't
// follow until something forces it. `tryFastForwardFromOrigin` only
// updates the worktree when origin is ahead of local; when the local
// tip is ahead of origin (the post-merge, pre-push state), it returns
// a no-op and the user sees "Pull completed" with no visible change.
// Reset against the branch ref explicitly so the worktree advances to
// the local tip regardless of whether the origin FF ran. The autostash
// above protects user edits, so --hard is safe here.
const localIntegrationTip = (await runGitCommand(
["rev-parse", "--verify", `refs/heads/${integration.integrationBranch}`],
rootDir,
5_000,
)).trim();
if (localIntegrationTip) {
await runGitCommand(["reset", "--hard", localIntegrationTip], rootDir, 10_000)
.catch((err) => {
// Log-and-continue: a failed worktree sync still leaves the ref
// advanced, so downstream stash-pop and audit emission proceed.
// The user's worktree just stays at its prior sha, matching today's
// behavior. Logged loudly so the failure is visible.
console.warn(
`[integration-pull] taskId=${taskId} worktree sync to ${localIntegrationTip.slice(0, 8)} failed (continuing): ${err instanceof Error ? err.message : String(err)}`,
);
});
}
const durationMs = Math.round(performance.now() - pullStart);
const toSha = (await runGitCommand(["rev-parse", "HEAD"], rootDir, 5_000)).trim();