feat(engine): merger auto-syncs project-root checkout after ref advance

After advanceIntegrationBranchRef ff-updates refs/heads/<integrationBranch>,
the merger now enumerates other worktrees on that branch and reconciles
each one's index + working tree to the new tip via syncWorktreeToHead.

Not a git pull — origin may still be at the previous tip without
pushAfterMerge, so pull --ff-only is a no-op and a naive stash/pull/pop
ends with the worktree restored to the old state. Instead the new
worktree-ref-sync helper:

  1. Diffs the worktree against the previous tip to isolate real edits
     from the stale-index "phantom diff" against the new HEAD.
  2. Snaps clean worktrees forward via reset --hard HEAD.
  3. In stash-and-ff mode with real edits, captures them as a binary patch
     against the previous tip, snaps to HEAD, then git apply --3way to
     restore. Untracked files are saved + restored separately. Patch
     conflicts surface as synced-with-pop-conflict with the patch left on
     disk for manual recovery.

Per-worktree outcome emitted as merge:auto-sync (new GitMutationType).
Per-step pull:fast-forward / stash:push / stash:pop / stash:pop-conflict
that pass through the auditor are tagged metadata.autoSync=true.

Isolated in its own try-catch so an auto-sync failure can't fail the
already-landed merge. Default behavior is mergeAdvanceAutoSync="stash-and-ff";
"off" preserves the legacy surprise behavior.

Backstopped by merger-auto-sync.slow.test.ts: clean-sync snaps both index
and files forward, ff-only with real edits is a no-op, stash-and-ff
preserves untracked locals across the snap, task worktrees on fusion/fn-*
are skipped, empty branch map emits nothing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-23 14:41:11 -07:00
parent db9928a4b1
commit 4c31e885bd
7 changed files with 689 additions and 3 deletions

View File

@@ -0,0 +1,20 @@
---
"@fusion/engine": minor
"@fusion/core": patch
---
feat(engine): merger auto-syncs project-root checkout after advancing integration-branch ref
Wires `mergeAdvanceAutoSync` into the merger's post-ref-advance code path. After `advanceIntegrationBranchRef` ff-updates `refs/heads/<integrationBranch>`, the merger now enumerates other worktrees still on that branch (typically the user's project-root checkout) and reconciles each one's index + working tree to the new tip via `syncWorktreeToHead`.
The reconciliation primitive is **not** a `git pull` — origin may still be at the previous tip (no `pushAfterMerge`), in which case `git pull --ff-only` is a no-op and a naive `stash → pull → pop` ends with the worktree restored to the old state. Instead `syncWorktreeToHead`:
1. Diffs the worktree against the *previous* tip to isolate real user edits from the stale-index "phantom diff" that looks like inverted commits.
2. When the worktree is clean against the previous tip, runs `git reset --hard HEAD` to snap index + files forward.
3. In `stash-and-ff` mode with real edits, captures them as a binary patch against the previous tip, snaps to HEAD, then `git apply --3way` to restore. Untracked files are copied to a temp dir and restored after the snap. Patch conflicts surface as `synced-with-pop-conflict` with the patch left on disk for manual recovery.
Each per-worktree attempt emits a `merge:auto-sync` audit event (new `GitMutationType`) with the outcome; the per-step `pull:fast-forward`, `stash:push`, `stash:pop`, and `stash:pop-conflict` events that pass through the auditor are tagged `metadata.autoSync = true` so downstream consumers can attribute them.
The user-facing effect: with the default `mergeAdvanceAutoSync: "stash-and-ff"`, after a Fusion task merges the user's `git status` in the project-root checkout becomes clean and the working tree shows the new commits' content — no manual `git reset` or Pull-button click required. Set `mergeAdvanceAutoSync: "off"` to restore the legacy behavior (the Merge Advance Notice banner still surfaces and the user pulls by hand).
Backstopped by `merger-auto-sync.slow.test.ts` covering: clean-sync snaps both index and files forward, ff-only with real edits is a no-op, stash-and-ff preserves untracked local files across the snap, task worktrees on `fusion/fn-*` branches are correctly skipped, and an empty branch map emits nothing.

View File

@@ -1,5 +1,5 @@
export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION } from "./types.js";
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure } from "./types.js";
export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION } from "./types.js";
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure } from "./types.js";
export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY } from "./types.js";
export { MOCK_PROVIDER_ID } from "./mock-provider-constants.js";
export type { MockProviderId, MockSessionPurpose } from "./mock-provider-constants.js";

View File

@@ -0,0 +1,197 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtempSync, realpathSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { execSync } from "node:child_process";
import type { RunAuditEventInput, TaskStore } from "@fusion/core";
import { createRunAuditor } from "../run-audit.js";
import { __test__ } from "../merger.js";
const { runMergeAdvanceAutoSync } = __test__;
function git(cwd: string, cmd: string): string {
return execSync(cmd, { cwd, stdio: "pipe" }).toString("utf-8").trim();
}
function testTempParent(): string {
return process.env.FUSION_TEST_WORKER_ROOT ?? tmpdir();
}
interface Fixture {
root: string;
upstream: string;
projectRoot: string;
taskWorktree: string;
previousSha: string;
newSha: string;
recorded: RunAuditEventInput[];
store: TaskStore;
}
function setupFixture(): Fixture {
const root = mkdtempSync(join(testTempParent(), "merger-auto-sync-"));
const upstream = join(root, "upstream.git");
const projectRoot = join(root, "project");
git(root, `git init --bare -b main "${upstream}"`);
git(root, `git clone "${upstream}" "${projectRoot}"`);
git(projectRoot, 'git config user.email "user@example.com"');
git(projectRoot, 'git config user.name "User"');
writeFileSync(join(projectRoot, "base.txt"), "v1\n");
git(projectRoot, "git add base.txt");
git(projectRoot, 'git commit -m "init"');
git(projectRoot, "git push -u origin main");
const previousSha = git(projectRoot, "git rev-parse HEAD");
// Build a task worktree on a fusion/fn-X branch and add a commit there —
// this emulates the merger's task worktree. Then advance refs/heads/main
// locally (no origin push) to that commit, leaving projectRoot's index +
// working tree pinned to `previousSha` while HEAD now resolves to `newSha`.
const taskWorktree = join(root, "task");
git(projectRoot, `git worktree add -b fusion/fn-test "${taskWorktree}"`);
writeFileSync(join(taskWorktree, "feature.txt"), "task work\n");
writeFileSync(join(taskWorktree, "base.txt"), "v2 from task\n");
git(taskWorktree, "git add -A");
git(taskWorktree, 'git commit -m "task commit"');
const newSha = git(taskWorktree, "git rev-parse HEAD");
git(projectRoot, `git update-ref refs/heads/main ${newSha}`);
const recorded: RunAuditEventInput[] = [];
const store = {
recordRunAuditEvent: vi.fn(async (input: RunAuditEventInput) => {
recorded.push(input);
}),
} as unknown as TaskStore;
return { root, upstream, projectRoot, taskWorktree, previousSha, newSha, recorded, store };
}
function makeAudit(store: TaskStore, taskId: string) {
return createRunAuditor(store, { runId: `run-${Date.now()}`, agentId: "merger", taskId, phase: "merge" });
}
describe("runMergeAdvanceAutoSync (post-local-ref-advance reconciliation)", () => {
let fx: Fixture;
beforeEach(() => {
fx = setupFixture();
});
afterEach(() => {
try {
rmSync(fx.root, { recursive: true, force: true });
} catch {
// best-effort
}
});
it("clean projectRoot: snaps index + worktree forward to newSha and emits outcome=clean-sync", async () => {
await runMergeAdvanceAutoSync({
store: fx.store,
audit: makeAudit(fx.store, "FN-TEST-1"),
taskId: "FN-TEST-1",
projectRootDir: fx.projectRoot,
integrationBranch: "main",
previousSha: fx.previousSha,
newSha: fx.newSha,
mode: "stash-and-ff",
});
const autoSync = fx.recorded.filter((e) => e.mutationType === "merge:auto-sync");
expect(autoSync).toHaveLength(1);
expect(autoSync[0].metadata).toMatchObject({
outcome: "clean-sync",
worktreePath: realpathSync(fx.projectRoot),
});
// The actual fix: worktree files now match newSha's tree.
expect(readFileSync(join(fx.projectRoot, "base.txt"), "utf-8")).toBe("v2 from task\n");
expect(readFileSync(join(fx.projectRoot, "feature.txt"), "utf-8")).toBe("task work\n");
// `git status` is now clean.
expect(git(fx.projectRoot, "git status --porcelain=v1")).toBe("");
});
it("ff-only mode + real edits: skipped-dirty, worktree untouched, no destructive operations", async () => {
writeFileSync(join(fx.projectRoot, "local.txt"), "user edit\n");
await runMergeAdvanceAutoSync({
store: fx.store,
audit: makeAudit(fx.store, "FN-TEST-2"),
taskId: "FN-TEST-2",
projectRootDir: fx.projectRoot,
integrationBranch: "main",
previousSha: fx.previousSha,
newSha: fx.newSha,
mode: "ff-only",
});
const autoSync = fx.recorded.filter((e) => e.mutationType === "merge:auto-sync");
expect(autoSync).toHaveLength(1);
expect(autoSync[0].metadata).toMatchObject({ outcome: "skipped-dirty" });
// worktree still pinned at previousSha — `base.txt` has the original v1.
expect(readFileSync(join(fx.projectRoot, "base.txt"), "utf-8")).toBe("v1\n");
// The untracked local file survives untouched.
expect(readFileSync(join(fx.projectRoot, "local.txt"), "utf-8")).toBe("user edit\n");
});
it("stash-and-ff + real edits on a non-conflicting file: edits restored on top of newSha", async () => {
// The user added a brand-new untracked file that doesn't conflict with
// the task's changes. After auto-sync the worktree should be at newSha
// AND the local file should still be present.
writeFileSync(join(fx.projectRoot, "local.txt"), "user edit\n");
await runMergeAdvanceAutoSync({
store: fx.store,
audit: makeAudit(fx.store, "FN-TEST-3"),
taskId: "FN-TEST-3",
projectRootDir: fx.projectRoot,
integrationBranch: "main",
previousSha: fx.previousSha,
newSha: fx.newSha,
mode: "stash-and-ff",
});
const autoSync = fx.recorded.filter((e) => e.mutationType === "merge:auto-sync");
expect(autoSync).toHaveLength(1);
expect(autoSync[0].metadata).toMatchObject({ outcome: "synced-with-edits-restored" });
// Task's content landed.
expect(readFileSync(join(fx.projectRoot, "base.txt"), "utf-8")).toBe("v2 from task\n");
expect(readFileSync(join(fx.projectRoot, "feature.txt"), "utf-8")).toBe("task work\n");
// Local untracked edit survived.
expect(readFileSync(join(fx.projectRoot, "local.txt"), "utf-8")).toBe("user edit\n");
});
it("emits structured merge:auto-sync per worktree and skips task worktrees on a different branch", async () => {
await runMergeAdvanceAutoSync({
store: fx.store,
audit: makeAudit(fx.store, "FN-TEST-4"),
taskId: "FN-TEST-4",
projectRootDir: fx.projectRoot,
integrationBranch: "main",
previousSha: fx.previousSha,
newSha: fx.newSha,
mode: "stash-and-ff",
});
const autoSync = fx.recorded.filter((e) => e.mutationType === "merge:auto-sync");
expect(autoSync).toHaveLength(1);
// Task worktree (on fusion/fn-test) is not in branchMap for `main`, so no
// event mentions it.
for (const event of autoSync) {
expect(event.target).not.toBe(fx.taskWorktree);
}
});
it("no other worktrees on integration branch → no audit emissions", async () => {
await runMergeAdvanceAutoSync({
store: fx.store,
audit: makeAudit(fx.store, "FN-TEST-5"),
taskId: "FN-TEST-5",
projectRootDir: fx.projectRoot,
integrationBranch: "nonexistent-branch",
previousSha: fx.previousSha,
newSha: fx.newSha,
mode: "stash-and-ff",
});
expect(fx.recorded).toHaveLength(0);
});
});

View File

@@ -62,6 +62,14 @@ export {
type SmartPullAuditEvent,
type SmartPullAuditEmitter,
} from "./smart-pull.js";
export {
syncWorktreeToHead,
type SyncWorktreeInput,
type SyncWorktreeResult,
type SyncMode,
type WorktreeSyncAuditEvent,
type WorktreeSyncAuditEmitter,
} from "./worktree-ref-sync.js";
export {
generateSyntheticRunId,
} from "./run-audit.js";

View File

@@ -89,7 +89,7 @@ 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, RemovalReason, removeWorktree, type WorktreePool } from "./worktree-pool.js";
import { classifyTaskWorktree, getRegisteredWorktreeBranchMap, 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";
@@ -125,11 +125,135 @@ import {
import { acquireTaskWorktree } from "./worktree-acquisition.js";
import { resolveIntegrationBranch } from "./integration-branch.js";
import { advanceIntegrationBranchRef, IntegrationBranchConcurrentAdvanceError } from "./merger-ref-update-advance.js";
import { syncWorktreeToHead, type SyncWorktreeResult } from "./worktree-ref-sync.js";
import { appendAutoWidenedScopeToPrompt, evaluateScopeAutoWiden } from "./merger-scope-auto-widen.js";
export { DiffVolumeRegressionError } from "./merger-diff-volume-gate.js";
export { IntegrationBranchConcurrentAdvanceError } from "./merger-ref-update-advance.js";
/**
* After `advanceIntegrationBranchRef` ff-updates `refs/heads/<integrationBranch>`,
* any other worktree still checked out on that branch keeps its index + working
* tree pinned at the previous tip. `git status` in such a worktree then shows
* the new commits inverted as "staged changes to be committed" — the surprise
* behavior that made many users think the merge had been silently reverted.
*
* This helper enumerates other worktrees on the integration branch and calls
* `syncWorktreeToHead` inside each — snap-forward when the worktree is clean
* against the previous tip, or capture-patch + reset + reapply when the user
* has real local edits. Each attempt emits a `merge:auto-sync` audit event
* with the outcome.
*
* Best-effort: any per-worktree failure is recorded as an audit event and the
* loop continues — the merge has already landed and the auto-sync is convenience.
*/
async function runMergeAdvanceAutoSync(input: {
store: TaskStore;
audit: RunAuditor;
taskId: string;
projectRootDir: string;
integrationBranch: string;
previousSha: string;
newSha: string;
mode: "ff-only" | "stash-and-ff";
}): Promise<void> {
const { audit, taskId, projectRootDir, integrationBranch, previousSha, newSha, mode } = input;
let branchMap: Map<string, string>;
try {
branchMap = await getRegisteredWorktreeBranchMap(projectRootDir);
} catch (err: unknown) {
await audit.git({
type: "merge:auto-sync",
target: projectRootDir,
metadata: {
taskId,
integrationBranch,
mode,
outcome: "enumeration-failed",
error: err instanceof Error ? err.message : String(err),
},
});
return;
}
const matchingWorktrees: string[] = [];
for (const [branch, worktreePath] of branchMap.entries()) {
if (branch === integrationBranch) {
matchingWorktrees.push(worktreePath);
}
}
if (matchingWorktrees.length === 0) {
return;
}
for (const worktreePath of matchingWorktrees) {
let result: SyncWorktreeResult;
try {
result = await syncWorktreeToHead({
worktreePath,
integrationBranch,
previousSha,
newSha,
mode,
taskId,
emit: async (event) => {
try {
await audit.git({
type: event.mutationType,
target: worktreePath,
metadata: { ...event.metadata, autoSync: true },
});
} catch {
// best-effort: never let inner audit failure abort the loop
}
},
});
} catch (err: unknown) {
await audit.git({
type: "merge:auto-sync",
target: worktreePath,
metadata: {
taskId,
integrationBranch,
mode,
newSha,
worktreePath,
outcome: "exception",
error: err instanceof Error ? err.message : String(err),
},
});
continue;
}
await audit.git({
type: "merge:auto-sync",
target: worktreePath,
metadata: {
taskId,
integrationBranch,
mode,
newSha,
previousSha,
worktreePath,
outcome: result.kind,
...(result.kind === "synced-with-pop-conflict"
? { conflictedFiles: result.conflictedFiles, patchPath: result.patchPath, stashedFiles: result.stashedFiles }
: {}),
...(result.kind === "synced-with-edits-restored"
? { stashedFiles: result.stashedFiles, untrackedRestored: result.untrackedRestored }
: {}),
...(result.kind === "failed"
? { stage: result.stage, error: result.error }
: {}),
...(result.kind === "skipped-dirty"
? { dirtyFiles: result.dirtyFiles, untrackedFiles: result.untrackedFiles }
: {}),
},
});
}
}
/** Conflict type classification for merge conflict resolution */
export type ConflictType =
| "lockfile-ours"
@@ -2459,6 +2583,7 @@ export const __test__ = {
applyAutostashBySha,
getAutostashDiff,
notifyAutostashOrphans,
runMergeAdvanceAutoSync,
};
export async function stashUnrelatedRootDirChanges(
@@ -9593,6 +9718,36 @@ export async function aiMergeTask(
mergerLog.log(
`${taskId}: ${integrationBranch} advanced to ${worktreeHeadSha.slice(0, 8)} via update-ref; your checked-out worktree at ${projectRootDir} is now behind`,
);
// Auto-sync other worktrees still on the integration branch so their
// index + working tree catch up to the new tip. When `off`, the legacy
// surprise behavior is preserved and the user pulls manually via the
// Merge Advance Notice banner. Isolated in its own try-catch because
// 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";
if (autoSyncMode !== "off") {
try {
await runMergeAdvanceAutoSync({
store,
audit,
taskId,
projectRootDir,
integrationBranch,
previousSha: expectedCurrentSha,
newSha: worktreeHeadSha,
mode: autoSyncMode,
});
} catch (syncErr: unknown) {
mergerLog.warn(
`${taskId}: mergeAdvanceAutoSync threw — continuing merge: ${syncErr instanceof Error ? syncErr.message : String(syncErr)}`,
);
}
}
}
} catch (advErr: unknown) {
const advMsg = advErr instanceof Error ? advErr.message : String(advErr);

View File

@@ -220,6 +220,41 @@ export type GitMutationType =
* ```
*/
| "merge:integration-ref-advance"
/**
* Emitted by the merger's post-ref-advance auto-sync hook for each other
* worktree it attempts to fast-forward (typically the user's project-root
* checkout). Records the per-worktree outcome of the
* `mergeAdvanceAutoSync` pipeline (`stash → ff → pop`, or pure `ff-only`).
* Per-worktree `pull:fast-forward`, `stash:push`, `stash:pop`, and
* `stash:pop-conflict` events are still emitted in addition, with
* `metadata.autoSync = true` so downstream consumers can attribute them.
*
* Metadata shape:
* ```ts
* {
* taskId: string;
* integrationBranch: string;
* mode: "ff-only" | "stash-and-ff";
* 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";
* error?: string;
* }
* ```
*/
| "merge:auto-sync"
/**
* Emitted when contamination recovery detects a foreign commit attributable
* to a `done` task that is not reachable from the integration branch — an

View File

@@ -0,0 +1,271 @@
import { execFile, spawn } from "node:child_process";
import { promisify } from "node:util";
import { mkdtempSync, copyFileSync, mkdirSync, readFileSync, rmSync, existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { tmpdir } from "node:os";
const execFileAsync = promisify(execFile);
export type SyncMode = "ff-only" | "stash-and-ff";
export interface WorktreeSyncAuditEvent {
mutationType: "pull:fast-forward" | "stash:push" | "stash:pop" | "stash:pop-conflict";
metadata: Record<string, unknown>;
}
export type WorktreeSyncAuditEmitter = (event: WorktreeSyncAuditEvent) => void | Promise<void>;
export interface SyncWorktreeInput {
worktreePath: string;
integrationBranch: string;
previousSha: string;
newSha: string;
mode: SyncMode;
taskId?: string;
emit?: WorktreeSyncAuditEmitter;
}
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: "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 };
async function runGit(args: string[], cwd: string, timeoutMs: number): Promise<{ stdout: string; stderr: string }> {
const result = await execFileAsync("git", args, {
cwd,
timeout: timeoutMs,
maxBuffer: 64 * 1024 * 1024,
encoding: "utf-8",
});
if (typeof result === "string") return { stdout: result, stderr: "" };
if (result && typeof result === "object") {
return {
stdout: String((result as { stdout?: unknown }).stdout ?? ""),
stderr: String((result as { stderr?: unknown }).stderr ?? ""),
};
}
return { stdout: "", stderr: "" };
}
function commandError(err: unknown): string {
if (err instanceof Error) {
const anyErr = err as Error & { stdout?: string; stderr?: string };
return [anyErr.stderr, anyErr.stdout, anyErr.message].filter(Boolean).join("\n").trim() || anyErr.message;
}
return String(err);
}
async function listFiles(cwd: string, args: string[]): Promise<string[]> {
try {
const { stdout } = await runGit(args, cwd, 10_000);
return stdout.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
} catch {
return [];
}
}
/**
* Bring a worktree's index + files forward to its current HEAD after the
* integration-branch ref was advanced *locally* (typically by the merger via
* `git update-ref`). This is NOT a `git pull` — origin may still be at the old
* tip, so a pull would be a no-op and would leave the worktree pinned to the
* stale state.
*
* Strategy:
* 1. Compare worktree contents against `previousSha` to isolate the user's
* *real* edits from the stale-index "phantom diff" against the new HEAD.
* 2. If no real edits and no untracked files exist, `git reset --hard HEAD`
* cleanly snaps both the index and the working tree forward to `newSha`.
* 3. With real edits in `stash-and-ff` mode, capture them as a binary patch
* 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.
*
* 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
* banner so the user can handle the worktree by hand.
*/
export async function syncWorktreeToHead(input: SyncWorktreeInput): Promise<SyncWorktreeResult> {
const { worktreePath, integrationBranch, previousSha, newSha, mode, taskId, emit } = input;
const emitSafe = async (event: WorktreeSyncAuditEvent): Promise<void> => {
if (!emit) return;
try {
await emit(event);
} catch {
// never let audit emission abort the sync
}
};
// Guards.
const branch = (await runGit(["rev-parse", "--abbrev-ref", "HEAD"], worktreePath, 5_000)).stdout.trim();
if (branch !== integrationBranch) {
return { kind: "skipped-not-on-branch", currentBranch: branch };
}
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).
let dirtyFiles: string[];
let untrackedFiles: string[];
try {
dirtyFiles = await listFiles(worktreePath, ["diff", "--name-only", previousSha]);
untrackedFiles = await listFiles(worktreePath, ["ls-files", "--others", "--exclude-standard"]);
} catch (err: unknown) {
return { kind: "failed", stage: "snapshot", error: commandError(err) };
}
const hasRealEdits = dirtyFiles.length > 0 || untrackedFiles.length > 0;
if (!hasRealEdits) {
try {
await runGit(["reset", "--hard", "HEAD"], worktreePath, 30_000);
} catch (err: unknown) {
return { kind: "failed", stage: "reset", error: commandError(err) };
}
await emitSafe({
mutationType: "pull:fast-forward",
metadata: { taskId, worktreePath, integrationBranch, fromSha: previousSha, toSha: newSha, succeeded: true, kind: "clean-sync" },
});
return { kind: "clean-sync", fromSha: previousSha, toSha: newSha };
}
if (mode === "ff-only") {
return { kind: "skipped-dirty", fromSha: previousSha, reason: "ff-only-mode-requires-clean-tree", dirtyFiles, untrackedFiles };
}
// stash-and-ff: snapshot real edits + untracked, snap, restore.
const stageDir = mkdtempSync(join(tmpdir(), "fusion-worktree-sync-"));
const patchPath = join(stageDir, "edits.patch");
const untrackedDir = join(stageDir, "untracked");
try {
mkdirSync(untrackedDir, { recursive: true });
// 1. Diff against previousSha (binary, full-file) captures only real edits.
let patch = "";
if (dirtyFiles.length > 0) {
const { stdout } = await runGit(["diff", "--binary", "--no-color", previousSha], worktreePath, 60_000);
patch = stdout;
}
// 2. Save untracked files.
for (const rel of untrackedFiles) {
const src = join(worktreePath, rel);
const dst = join(untrackedDir, rel);
mkdirSync(dirname(dst), { recursive: true });
try {
copyFileSync(src, dst);
} catch {
// best-effort; missing entries skipped
}
}
await emitSafe({
mutationType: "stash:push",
metadata: {
taskId,
worktreePath,
stashedFiles: dirtyFiles,
untrackedCount: untrackedFiles.length,
patchPath,
kind: "patch-snapshot",
},
});
// 3. Snap worktree+index to HEAD (NEW).
try {
await runGit(["reset", "--hard", "HEAD"], worktreePath, 30_000);
} catch (err: unknown) {
return { kind: "failed", stage: "reset", error: commandError(err) };
}
await emitSafe({
mutationType: "pull:fast-forward",
metadata: { taskId, worktreePath, integrationBranch, fromSha: previousSha, toSha: newSha, succeeded: true, kind: "snap-after-snapshot" },
});
// 4. Reapply patch.
let popConflict = false;
const conflictedFiles: string[] = [];
if (patch.length > 0) {
try {
await new Promise<void>((resolve, reject) => {
const child = spawn("git", ["apply", "--3way", "--whitespace=nowarn"], { cwd: worktreePath });
let stderr = "";
child.stderr.on("data", (chunk: Buffer | string) => { stderr += chunk.toString(); });
child.on("error", reject);
child.on("close", (code) => {
if (code === 0) resolve();
else reject(new Error(`git apply exited with code ${code}: ${stderr}`));
});
child.stdin.write(patch);
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.`,
},
});
}
}
// 5. Restore untracked files.
const restored: string[] = [];
for (const rel of untrackedFiles) {
const src = join(untrackedDir, rel);
const dst = join(worktreePath, rel);
if (!existsSync(src)) continue;
try {
mkdirSync(dirname(dst), { recursive: true });
const data = readFileSync(src);
await import("node:fs").then((fs) => fs.writeFileSync(dst, data));
restored.push(rel);
} catch {
// best-effort
}
}
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 };
}
// 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 };
} catch (err: unknown) {
return { kind: "failed", stage: "apply", error: commandError(err) };
}
}