diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index dfef663433..3103675b6c 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -762,6 +762,8 @@ export type { FinanceByKind, AgentWakeupResponse, AgentWakeupSkipped, + GitWorktreeBranchAncestryVerdict, + GitWorktreeBranchIncoherenceEvidence, HeartbeatRun, HeartbeatRunEvent, HeartbeatRunStatusPhase, diff --git a/packages/shared/src/types/heartbeat.ts b/packages/shared/src/types/heartbeat.ts index e8a5b9b519..427ebab800 100644 --- a/packages/shared/src/types/heartbeat.ts +++ b/packages/shared/src/types/heartbeat.ts @@ -8,6 +8,42 @@ import type { WakeupRequestStatus, } from "../constants.js"; +export type GitWorktreeBranchAncestryVerdict = "ancestor" | "diverged" | "unknown"; + +export interface GitWorktreeBranchIncoherenceEvidence { + reason: "git_worktree_branch_incoherence"; + fingerprint: string; + sourceIssueId: string | null; + sourceIdentifier: string | null; + executionWorkspaceId: string | null; + worktreePath: string; + repoRoot: string; + expectedBranch: string; + actualBranch: string | null; + cleanliness: "clean" | "dirty" | "unknown"; + statusEntryCount: number | null; + provenance: { + expectedBranchRef: string; + actualBranchRef: string | null; + registeredBranchRef: string | null; + registeredPathFound: boolean; + registeredBranchMatchesHead: boolean; + expectedBranchExists: boolean; + actualBranchExists: boolean | null; + expectedHeadSha: string | null; + actualHeadSha: string | null; + sameHead: boolean; + ancestryVerdict: GitWorktreeBranchAncestryVerdict; + plainLanguageReason: string; + }; + safeRepair: { + eligible: boolean; + attempted: boolean; + succeeded: boolean; + reason: string; + }; +} + export interface HeartbeatRun { id: string; companyId: string; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 8d73510b1c..aaf2883ea4 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -467,6 +467,8 @@ export type { FinanceEvent, FinanceSummary, FinanceByBiller, FinanceByKind } fro export type { AgentWakeupResponse, AgentWakeupSkipped, + GitWorktreeBranchAncestryVerdict, + GitWorktreeBranchIncoherenceEvidence, HeartbeatRun, HeartbeatRunEvent, HeartbeatRunStatusPhase, diff --git a/packages/shared/src/types/instance.ts b/packages/shared/src/types/instance.ts index 12ad91437a..038fac8068 100644 --- a/packages/shared/src/types/instance.ts +++ b/packages/shared/src/types/instance.ts @@ -58,6 +58,7 @@ export interface InstanceExperimentalSettings { enableServerInfoDebugView: boolean; autoRestartDevServerWhenIdle: boolean; enableIssueGraphLivenessAutoRecovery: boolean; + enableWorkspaceBranchReconcileForward: boolean; issueGraphLivenessAutoRecoveryLookbackHours: number; } diff --git a/packages/shared/src/validators/instance.test.ts b/packages/shared/src/validators/instance.test.ts index de84c6f7fa..f754afea62 100644 --- a/packages/shared/src/validators/instance.test.ts +++ b/packages/shared/src/validators/instance.test.ts @@ -11,6 +11,12 @@ describe("instance experimental settings validators", () => { expect(settings.enableServerInfoDebugView).toBe(false); }); + it("defaults workspace branch forward reconciliation off", () => { + const settings = instanceExperimentalSettingsSchema.parse({}); + + expect(settings.enableWorkspaceBranchReconcileForward).toBe(false); + }); + it("accepts server info debug view patches", () => { expect( patchInstanceExperimentalSettingsSchema.parse({ @@ -20,4 +26,14 @@ describe("instance experimental settings validators", () => { enableServerInfoDebugView: true, }); }); + + it("accepts workspace branch forward reconciliation patches", () => { + expect( + patchInstanceExperimentalSettingsSchema.parse({ + enableWorkspaceBranchReconcileForward: true, + }), + ).toEqual({ + enableWorkspaceBranchReconcileForward: true, + }); + }); }); diff --git a/packages/shared/src/validators/instance.ts b/packages/shared/src/validators/instance.ts index 4831e7efa7..c405f217a5 100644 --- a/packages/shared/src/validators/instance.ts +++ b/packages/shared/src/validators/instance.ts @@ -52,6 +52,7 @@ export const instanceExperimentalSettingsSchema = z.object({ enableServerInfoDebugView: z.boolean().default(false), autoRestartDevServerWhenIdle: z.boolean().default(false), enableIssueGraphLivenessAutoRecovery: z.boolean().default(false), + enableWorkspaceBranchReconcileForward: z.boolean().default(false), issueGraphLivenessAutoRecoveryLookbackHours: z .number() .int() diff --git a/server/src/__tests__/instance-settings-routes.test.ts b/server/src/__tests__/instance-settings-routes.test.ts index 05c0210e73..b9fa242b16 100644 --- a/server/src/__tests__/instance-settings-routes.test.ts +++ b/server/src/__tests__/instance-settings-routes.test.ts @@ -84,6 +84,7 @@ describe("instance settings routes", () => { enableServerInfoDebugView: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: true, + enableWorkspaceBranchReconcileForward: false, issueGraphLivenessAutoRecoveryLookbackHours: 24, }, createdAt: "2026-06-20T00:00:00.000Z", @@ -105,6 +106,7 @@ describe("instance settings routes", () => { enableServerInfoDebugView: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: true, + enableWorkspaceBranchReconcileForward: false, issueGraphLivenessAutoRecoveryLookbackHours: 24, }); mockInstanceSettingsService.update.mockResolvedValue({ @@ -124,6 +126,7 @@ describe("instance settings routes", () => { enableServerInfoDebugView: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: true, + enableWorkspaceBranchReconcileForward: false, issueGraphLivenessAutoRecoveryLookbackHours: 24, }, createdAt: "2026-06-20T00:00:00.000Z", @@ -150,6 +153,7 @@ describe("instance settings routes", () => { enableServerInfoDebugView: true, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: true, + enableWorkspaceBranchReconcileForward: false, issueGraphLivenessAutoRecoveryLookbackHours: 24, }, }); @@ -204,6 +208,7 @@ describe("instance settings routes", () => { enableServerInfoDebugView: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: true, + enableWorkspaceBranchReconcileForward: false, issueGraphLivenessAutoRecoveryLookbackHours: 24, }); diff --git a/server/src/__tests__/instance-settings-service.test.ts b/server/src/__tests__/instance-settings-service.test.ts index 0d00f3c0ef..1ac0eeb4fb 100644 --- a/server/src/__tests__/instance-settings-service.test.ts +++ b/server/src/__tests__/instance-settings-service.test.ts @@ -13,6 +13,7 @@ describe("instance settings service", () => { enableServerInfoDebugView: true, autoRestartDevServerWhenIdle: true, enableIssueGraphLivenessAutoRecovery: true, + enableWorkspaceBranchReconcileForward: true, issueGraphLivenessAutoRecoveryLookbackHours: 48, enableNewestFirstIssueThread: true, })).toEqual({ @@ -29,6 +30,7 @@ describe("instance settings service", () => { enableServerInfoDebugView: true, autoRestartDevServerWhenIdle: true, enableIssueGraphLivenessAutoRecovery: true, + enableWorkspaceBranchReconcileForward: true, issueGraphLivenessAutoRecoveryLookbackHours: 48, }); }); @@ -58,6 +60,15 @@ describe("instance settings service", () => { ).toBe(false); }); + it("defaults enableWorkspaceBranchReconcileForward to false for empty and legacy stored settings", () => { + expect(normalizeExperimentalSettings(undefined).enableWorkspaceBranchReconcileForward).toBe(false); + expect(normalizeExperimentalSettings({}).enableWorkspaceBranchReconcileForward).toBe(false); + expect( + normalizeExperimentalSettings({ enableIssueGraphLivenessAutoRecovery: true }) + .enableWorkspaceBranchReconcileForward, + ).toBe(false); + }); + it("round-trips an enableConferenceRoomChat patch through the update merge", () => { // updateExperimental merges `{ ...normalize(current), ...patch }` and // re-normalizes; emulate that to prove the flag survives the roundtrip diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index c936b95f7b..d0fd0d6846 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -59,6 +59,34 @@ function stableStringifyForTest(value: unknown): string { } return JSON.stringify(value); } + +function workspaceBranchIncoherenceFingerprintForTest(input: { + sourceIssueId: string | null; + executionWorkspaceId: string | null; + worktreePath: string; + expectedBranch: string; + actualBranch: string | null; + cleanliness: "clean" | "dirty" | "unknown"; + expectedHeadSha: string | null; + actualHeadSha: string | null; +}) { + const digest = createHash("sha256") + .update(stableStringifyForTest({ + version: 1, + reason: "git_worktree_branch_incoherence", + sourceIssueId: input.sourceIssueId, + executionWorkspaceId: input.executionWorkspaceId, + worktreePath: path.resolve(input.worktreePath), + expectedBranch: input.expectedBranch, + actualBranch: input.actualBranch, + cleanliness: input.cleanliness, + expectedHeadSha: input.expectedHeadSha, + actualHeadSha: input.actualHeadSha, + })) + .digest("hex"); + return `workspace_incoherence:v1:sha256:${digest}`; +} + const leasedRunIds = new Set(); const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -2294,6 +2322,8 @@ describe("realizeExecutionWorkspace", () => { expectedBranchExists: true, actualBranchExists: true, sameHead: true, + ancestryVerdict: "ancestor", + plainLanguageReason: expect.stringContaining("same commit"), }), safeRepair: expect.objectContaining({ eligible: false, @@ -2394,43 +2424,64 @@ describe("realizeExecutionWorkspace", () => { await runGit(initial.cwd, ["add", "publish.txt"]); await runGit(initial.cwd, ["commit", "-m", "Add publish branch work"]); - await expect(ensurePersistedExecutionWorkspaceAvailable({ - base: { - baseCwd: repoRoot, - source: "project_primary", - projectId: "project-1", - workspaceId: "workspace-1", - repoUrl: null, - repoRef: "HEAD", - }, - workspace: { - id: "execution-workspace-3", - mode: "isolated_workspace", - strategyType: "git_worktree", - cwd: initial.cwd, - providerRef: initial.worktreePath, - projectId: "project-1", - projectWorkspaceId: "workspace-1", - repoUrl: null, - baseRef: "HEAD", - branchName: initial.branchName, - }, - issue: { - id: "issue-3", - identifier: "PAP-456", - title: "Keep persisted branch coherent", - }, - agent: { - id: "agent-1", - name: "Codex Coder", - companyId: "company-1", - }, - })).rejects.toMatchObject({ + if (!initial.branchName) throw new Error("expected realized worktree branch name"); + const expectedHeadSha = await readGit(repoRoot, ["rev-parse", `refs/heads/${initial.branchName}^{commit}`]); + const actualHeadSha = await readGit(initial.cwd, ["rev-parse", "HEAD"]); + const expectedFingerprint = workspaceBranchIncoherenceFingerprintForTest({ + sourceIssueId: "issue-3", + executionWorkspaceId: "execution-workspace-3", + worktreePath: initial.cwd, + expectedBranch: initial.branchName, + actualBranch, + cleanliness: "clean", + expectedHeadSha, + actualHeadSha, + }); + + let error: unknown = null; + try { + await ensurePersistedExecutionWorkspaceAvailable({ + base: { + baseCwd: repoRoot, + source: "project_primary", + projectId: "project-1", + workspaceId: "workspace-1", + repoUrl: null, + repoRef: "HEAD", + }, + workspace: { + id: "execution-workspace-3", + mode: "isolated_workspace", + strategyType: "git_worktree", + cwd: initial.cwd, + providerRef: initial.worktreePath, + projectId: "project-1", + projectWorkspaceId: "workspace-1", + repoUrl: null, + baseRef: "HEAD", + branchName: initial.branchName, + }, + issue: { + id: "issue-3", + identifier: "PAP-456", + title: "Keep persisted branch coherent", + }, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + }); + } catch (err) { + error = err; + } + + expect(error).toMatchObject({ code: "workspace_validation_failed", resultJson: { workspaceValidation: expect.objectContaining({ reason: "git_worktree_branch_incoherence", - fingerprint: expect.stringMatching(/^workspace_incoherence:v1:sha256:/), + fingerprint: expectedFingerprint, sourceIssueId: "issue-3", sourceIdentifier: "PAP-456", executionWorkspaceId: "execution-workspace-3", @@ -2441,6 +2492,8 @@ describe("realizeExecutionWorkspace", () => { expectedBranchExists: true, actualBranchExists: true, sameHead: false, + ancestryVerdict: "ancestor", + plainLanguageReason: expect.stringContaining("forward of the recorded branch"), }), safeRepair: expect.objectContaining({ eligible: false, @@ -2453,6 +2506,165 @@ describe("realizeExecutionWorkspace", () => { }); }, 15_000); + it("classifies persisted git worktree branch incoherence as diverged when the checked-out branch is not forward", async () => { + const repoRoot = await createTempRepo(); + const expectedBranch = "PAP-457-recorded-work"; + const actualBranch = "PAP-457-sibling-work"; + const worktreePath = path.join(repoRoot, ".paperclip", "worktrees", expectedBranch); + + await fs.mkdir(path.dirname(worktreePath), { recursive: true }); + await runGit(repoRoot, ["branch", expectedBranch]); + await runGit(repoRoot, ["worktree", "add", "-b", actualBranch, worktreePath, "HEAD"]); + + await runGit(repoRoot, ["checkout", expectedBranch]); + await fs.writeFile(path.join(repoRoot, "recorded.txt"), "recorded branch work\n", "utf8"); + await runGit(repoRoot, ["add", "recorded.txt"]); + await runGit(repoRoot, ["commit", "-m", "Add recorded branch work"]); + + await fs.writeFile(path.join(worktreePath, "actual.txt"), "actual branch work\n", "utf8"); + await runGit(worktreePath, ["add", "actual.txt"]); + await runGit(worktreePath, ["commit", "-m", "Add actual branch work"]); + + let error: unknown = null; + try { + await ensurePersistedExecutionWorkspaceAvailable({ + base: { + baseCwd: repoRoot, + source: "project_primary", + projectId: "project-1", + workspaceId: "workspace-1", + repoUrl: null, + repoRef: "HEAD", + }, + workspace: { + id: "execution-workspace-diverged", + mode: "isolated_workspace", + strategyType: "git_worktree", + cwd: worktreePath, + providerRef: worktreePath, + projectId: "project-1", + projectWorkspaceId: "workspace-1", + repoUrl: null, + baseRef: "HEAD", + branchName: expectedBranch, + }, + issue: { + id: "issue-diverged", + identifier: "PAP-457", + title: "Classify diverged branch incoherence", + }, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + }); + } catch (err) { + error = err; + } + + expect(error).toMatchObject({ + code: "workspace_validation_failed", + resultJson: { + workspaceValidation: expect.objectContaining({ + reason: "git_worktree_branch_incoherence", + sourceIssueId: "issue-diverged", + sourceIdentifier: "PAP-457", + executionWorkspaceId: "execution-workspace-diverged", + expectedBranch, + actualBranch, + cleanliness: "clean", + provenance: expect.objectContaining({ + expectedBranchExists: true, + actualBranchExists: true, + sameHead: false, + ancestryVerdict: "diverged", + plainLanguageReason: expect.stringContaining("cannot prove a forward-only reconciliation"), + }), + }), + }, + }); + }, 15_000); + + it("classifies persisted git worktree branch incoherence as unknown when the recorded branch was deleted", async () => { + const repoRoot = await createTempRepo(); + const expectedBranch = "PAP-458-deleted-recorded-branch"; + const actualBranch = "PAP-458-actual-work"; + const worktreePath = path.join(repoRoot, ".paperclip", "worktrees", expectedBranch); + + await fs.mkdir(path.dirname(worktreePath), { recursive: true }); + await runGit(repoRoot, ["branch", expectedBranch]); + await runGit(repoRoot, ["worktree", "add", "-b", actualBranch, worktreePath, "HEAD"]); + await runGit(repoRoot, ["branch", "-D", expectedBranch]); + + let error: unknown = null; + try { + await ensurePersistedExecutionWorkspaceAvailable({ + base: { + baseCwd: repoRoot, + source: "project_primary", + projectId: "project-1", + workspaceId: "workspace-1", + repoUrl: null, + repoRef: "HEAD", + }, + workspace: { + id: "execution-workspace-deleted-branch", + mode: "isolated_workspace", + strategyType: "git_worktree", + cwd: worktreePath, + providerRef: worktreePath, + projectId: "project-1", + projectWorkspaceId: "workspace-1", + repoUrl: null, + baseRef: "HEAD", + branchName: expectedBranch, + }, + issue: { + id: "issue-deleted-branch", + identifier: "PAP-458", + title: "Classify deleted branch ancestry", + }, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + }); + } catch (err) { + error = err; + } + + expect(error).toMatchObject({ + code: "workspace_validation_failed", + resultJson: { + workspaceValidation: expect.objectContaining({ + reason: "git_worktree_branch_incoherence", + sourceIssueId: "issue-deleted-branch", + sourceIdentifier: "PAP-458", + executionWorkspaceId: "execution-workspace-deleted-branch", + expectedBranch, + actualBranch, + cleanliness: "clean", + provenance: expect.objectContaining({ + expectedBranchExists: false, + actualBranchExists: true, + expectedHeadSha: null, + sameHead: false, + ancestryVerdict: "unknown", + plainLanguageReason: expect.stringContaining("missing a resolvable HEAD commit"), + }), + safeRepair: expect.objectContaining({ + eligible: false, + attempted: false, + succeeded: false, + reason: "expected branch does not exist", + }), + }), + }, + }); + }, 15_000); + it("does not reuse a missing persisted local filesystem workspace", async () => { const baseCwd = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-workspace-base-")); const missingCwd = path.join(baseCwd, "missing-workspace"); diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index 8b99bdedb7..f61c966cb3 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -57,6 +57,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableServerInfoDebugView: parsed.data.enableServerInfoDebugView ?? false, autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false, enableIssueGraphLivenessAutoRecovery: parsed.data.enableIssueGraphLivenessAutoRecovery ?? false, + enableWorkspaceBranchReconcileForward: parsed.data.enableWorkspaceBranchReconcileForward ?? false, issueGraphLivenessAutoRecoveryLookbackHours: parsed.data.issueGraphLivenessAutoRecoveryLookbackHours ?? DEFAULT_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS, @@ -76,6 +77,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableServerInfoDebugView: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: false, + enableWorkspaceBranchReconcileForward: false, issueGraphLivenessAutoRecoveryLookbackHours: DEFAULT_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS, }; diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index 866e189831..14af9af6b0 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -10,6 +10,8 @@ import type { Db } from "@paperclipai/db"; import { executionWorkspaces, projectWorkspaces, workspaceRuntimeServices } from "@paperclipai/db"; import { listWorkspaceServiceCommandDefinitions, + type GitWorktreeBranchAncestryVerdict, + type GitWorktreeBranchIncoherenceEvidence as SharedGitWorktreeBranchIncoherenceEvidence, type WorkspaceRuntimeDesiredState, type WorkspaceRuntimeServiceStateMap, } from "@paperclipai/shared"; @@ -653,39 +655,9 @@ async function remoteExists(repoRoot: string, remote: string): Promise const GIT_WORKTREE_BRANCH_INCOHERENCE_REASON = "git_worktree_branch_incoherence"; -type GitWorktreeCleanliness = "clean" | "dirty" | "unknown"; +type GitWorktreeCleanliness = SharedGitWorktreeBranchIncoherenceEvidence["cleanliness"]; -type GitWorktreeBranchIncoherenceEvidence = { - reason: typeof GIT_WORKTREE_BRANCH_INCOHERENCE_REASON; - fingerprint: string; - sourceIssueId: string | null; - sourceIdentifier: string | null; - executionWorkspaceId: string | null; - worktreePath: string; - repoRoot: string; - expectedBranch: string; - actualBranch: string | null; - cleanliness: GitWorktreeCleanliness; - statusEntryCount: number | null; - provenance: { - expectedBranchRef: string; - actualBranchRef: string | null; - registeredBranchRef: string | null; - registeredPathFound: boolean; - registeredBranchMatchesHead: boolean; - expectedBranchExists: boolean; - actualBranchExists: boolean | null; - expectedHeadSha: string | null; - actualHeadSha: string | null; - sameHead: boolean; - }; - safeRepair: { - eligible: boolean; - attempted: boolean; - succeeded: boolean; - reason: string; - }; -}; +type GitWorktreeBranchIncoherenceEvidence = SharedGitWorktreeBranchIncoherenceEvidence; function formatBranchForMessage(branch: string | null | undefined) { return branch && branch.length > 0 ? branch : ""; @@ -718,6 +690,48 @@ function fingerprintWorkspaceBranchIncoherence(input: { return `workspace_incoherence:v1:sha256:${digest}`; } +async function getGitWorktreeBranchAncestryVerdict(input: { + repoRoot: string; + expectedHeadSha: string | null; + actualHeadSha: string | null; +}): Promise { + if (!input.expectedHeadSha || !input.actualHeadSha) return "unknown"; + + const proc = await executeProcess({ + command: "git", + args: ["merge-base", "--is-ancestor", input.expectedHeadSha, input.actualHeadSha], + cwd: input.repoRoot, + }).catch(() => null); + if (!proc) return "unknown"; + if (proc.code === 0) return "ancestor"; + if (proc.code === 1) return "diverged"; + return "unknown"; +} + +function explainGitWorktreeBranchIncoherence(input: { + expectedBranchName: string; + actualBranchName: string | null; + expectedHeadSha: string | null; + actualHeadSha: string | null; + sameHead: boolean; + ancestryVerdict: GitWorktreeBranchAncestryVerdict; +}) { + const actualBranch = formatBranchForMessage(input.actualBranchName); + if (!input.expectedHeadSha || !input.actualHeadSha) { + return `Paperclip could not determine branch ancestry because the recorded branch "${input.expectedBranchName}" or checked-out branch "${actualBranch}" is missing a resolvable HEAD commit.`; + } + if (input.sameHead) { + return `The recorded branch "${input.expectedBranchName}" and checked-out branch "${actualBranch}" resolve to the same commit, so the mismatch is branch metadata rather than commit divergence.`; + } + if (input.ancestryVerdict === "ancestor") { + return `The recorded branch "${input.expectedBranchName}" is an ancestor of the checked-out branch "${actualBranch}", so the checked-out branch is forward of the recorded branch.`; + } + if (input.ancestryVerdict === "diverged") { + return `The recorded branch "${input.expectedBranchName}" is not an ancestor of the checked-out branch "${actualBranch}", so Paperclip cannot prove a forward-only reconciliation.`; + } + return `Paperclip could not determine whether the checked-out branch "${actualBranch}" is forward of the recorded branch "${input.expectedBranchName}".`; +} + async function inspectGitWorktreeBranchIncoherence(input: { repoRoot: string; worktreePath: string; @@ -749,6 +763,19 @@ async function inspectGitWorktreeBranchIncoherence(input: { const registeredBranchMatchesHead = Boolean(registered && registeredBranchRef === actualBranchRef); const sameHead = Boolean(expectedHeadSha && actualHeadSha && expectedHeadSha === actualHeadSha); const expectedBranchExists = Boolean(expectedHeadSha); + const ancestryVerdict = await getGitWorktreeBranchAncestryVerdict({ + repoRoot: input.repoRoot, + expectedHeadSha, + actualHeadSha, + }); + const plainLanguageReason = explainGitWorktreeBranchIncoherence({ + expectedBranchName: input.expectedBranchName, + actualBranchName: input.actualBranchName, + expectedHeadSha, + actualHeadSha, + sameHead, + ancestryVerdict, + }); const eligible = cleanliness === "clean" && expectedBranchExists && sameHead && registeredBranchMatchesHead; const safeRepairReason = eligible ? "clean worktree and expected branch points at the current HEAD" @@ -797,6 +824,8 @@ async function inspectGitWorktreeBranchIncoherence(input: { expectedHeadSha, actualHeadSha, sameHead, + ancestryVerdict, + plainLanguageReason, }, safeRepair: { eligible, diff --git a/ui/src/pages/InstanceExperimentalSettings.test.tsx b/ui/src/pages/InstanceExperimentalSettings.test.tsx index 8f5b5cf669..ac41aebed8 100644 --- a/ui/src/pages/InstanceExperimentalSettings.test.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.test.tsx @@ -63,6 +63,7 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload { autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: false, issueGraphLivenessAutoRecoveryLookbackHours: 24, + enableWorkspaceBranchReconcileForward: false, }; }