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:
20
.changeset/merger-auto-sync-hook.md
Normal file
20
.changeset/merger-auto-sync-hook.md
Normal 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.
|
||||
@@ -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";
|
||||
|
||||
197
packages/engine/src/__tests__/merger-auto-sync.slow.test.ts
Normal file
197
packages/engine/src/__tests__/merger-auto-sync.slow.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
271
packages/engine/src/worktree-ref-sync.ts
Normal file
271
packages/engine/src/worktree-ref-sync.ts
Normal 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) };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user