Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-rebases-reconcile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"worktree-kit": patch
---

Make feature update reconciliation automatic for semantic remote changes, preserve locally rebased histories when tracking refs are stale, and report unresolved work with actionable branch details.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ wt update --cleanup
4. Re-detects parent branches from the reconciled tips
5. Rebases feature branches in correct order — parents before children

Feature reconciliation keeps equal and local-only tips unchanged, fast-forwards remote-only advances, and automatically accepts a patch-equivalent remote rewrite after saving the old tip under `refs/worktree-kit/recovery/`. Genuine local/remote divergence prompts in interactive mode, defaulting to rebase. Non-interactive runs must choose `--reconcile rebase` or leave the branch unresolved with `--reconcile abort`; destructive `--reconcile reset` is accepted only for an explicitly named branch and also creates a recovery ref. A dirty worktree that would have to move is left untouched together with its dependent subtree.
Feature reconciliation compares patch-equivalent commits in both directions. Equal, local-only, and stale patch-equivalent tracking histories keep the local tip unchanged; remote-only semantic work is rebased automatically after saving the old tip under `refs/worktree-kit/recovery/`; and genuine two-sided semantic divergence is also rebased automatically. There are no per-branch prompts in the default workflow. Use `--reconcile abort` to leave a divergent branch unresolved, `--reconcile rebase` as an explicit compatible form of the default, or targeted `--reconcile reset <branch>` only when you explicitly intend to accept the tracking ref. A dirty worktree, comparison failure, or conflict is left untouched together with its dependent subtree and is reported with the affected branches and next action. Normal output shows only changed or actionable reconciliation rows; pass `--verbose` for unchanged diagnostics and Git details.

Use `--dry-run` to inspect classifications, policies, recovery actions, root synchronization, and the final rebase plan without changing refs, running hooks, or opening a reconciliation prompt.

Expand Down
156 changes: 147 additions & 9 deletions src/application/use-cases/update-worktrees.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@ describe("updateWorktrees — feature tracking reconciliation (WTK-70)", () => {
expect(output.unresolved).toBe(false);
});

test("R3: patch-equivalent rewrite saves a recovery ref and realigns", async () => {
test("R2: patch-equivalent stale tracking history keeps the local rebased tip", async () => {
const createRecoveryRefCalls: string[] = [];
const resetHardToRefCalls: { worktreePath: string; ref: string }[] = [];
const git = reconciliationGit({
Expand All @@ -314,9 +314,68 @@ describe("updateWorktrees — feature tracking reconciliation (WTK-70)", () => {
});
const output = expectOk(await updateWorktrees({ dryRun: false }, { git }));

expect(createRecoveryRefCalls).toEqual([]);
expect(resetHardToRefCalls).toEqual([]);
expect(output.reconciliations[0]).toMatchObject({ state: "stale-tracking", action: "unchanged" });
});

test("R3: remote-only semantic work rebases automatically without a prompt", async () => {
const createRecoveryRefCalls: string[] = [];
const rebaseCalls: FakeRebaseCall[] = [];
let prompts = 0;
const git = reconciliationGit({
commitCountMap: new Map([
...flatBranchesConfig([mainWt, featureA]).commitCountMap,
["feature-a..fork/topic", 1],
["fork/topic..feature-a", 1],
]),
revListCherryPickMap: new Map([
["fork/topic...feature-a", []],
["feature-a...fork/topic", ["remote"]],
]),
createRecoveryRefCalls,
rebaseCalls,
});
const output = expectOk(
await updateWorktrees(
{ dryRun: false },
{
git,
chooseReconciliation: async () => {
prompts += 1;
return "abort";
},
},
),
);

expect(prompts).toBe(0);
expect(createRecoveryRefCalls).toEqual(["feature-a"]);
expect(resetHardToRefCalls).toEqual([{ worktreePath: "/repo-a", ref: "fork/topic" }]);
expect(output.reconciliations[0]).toMatchObject({ state: "remote-rewrite", action: "realigned" });
expect(rebaseCalls[0]).toMatchObject({ worktreePath: "/repo-a", onto: "fork/topic" });
expect(output.reconciliations[0]).toMatchObject({ state: "remote-only", action: "rebased" });
});

test("R2: explicit rebase preserves local-only history", async () => {
const recoveryCalls: string[] = [];
const rebaseCalls: FakeRebaseCall[] = [];
const git = reconciliationGit({
commitCountMap: new Map([
...flatBranchesConfig([mainWt, featureA]).commitCountMap,
["feature-a..fork/topic", 1],
["fork/topic..feature-a", 1],
]),
revListCherryPickMap: new Map([
["fork/topic...feature-a", ["local"]],
["feature-a...fork/topic", []],
]),
createRecoveryRefCalls: recoveryCalls,
rebaseCalls,
});
const output = expectOk(await updateWorktrees({ dryRun: false, reconcile: "rebase" }, { git }));

expect(recoveryCalls).toEqual([]);
expect(rebaseCalls.some((call) => call.onto === "fork/topic")).toBe(false);
expect(output.reconciliations[0]).toMatchObject({ state: "local-only", action: "unchanged" });
});

test("R4: genuine divergence rebases only under the selected policy", async () => {
Expand All @@ -328,7 +387,10 @@ describe("updateWorktrees — feature tracking reconciliation (WTK-70)", () => {
["feature-a..fork/topic", 1],
["fork/topic..feature-a", 1],
]),
revListCherryPickMap: new Map([["fork/topic...feature-a", ["local"]]]),
revListCherryPickMap: new Map([
["fork/topic...feature-a", ["local"]],
["feature-a...fork/topic", ["remote"]],
]),
createRecoveryRefCalls: recoveryCalls,
rebaseCalls,
});
Expand All @@ -339,6 +401,80 @@ describe("updateWorktrees — feature tracking reconciliation (WTK-70)", () => {
expect(output.reconciliations[0]).toMatchObject({ state: "diverged", action: "rebased" });
});

test("R6: commit-count failures retain the exact comparison error", async () => {
const output = expectOk(
await updateWorktrees(
{ dryRun: false },
{
git: reconciliationGit({
commitCountFailures: new Map([
["feature-a..fork/topic", { code: "UNKNOWN", message: "count unavailable" }],
]),
commitCountMap: new Map([["fork/topic..feature-a", 0]]),
}),
},
),
);

expect(output.reconciliations[0]).toMatchObject({
action: "aborted",
warning:
"Failed to compare local and tracking commit counts: remote tracking count (feature-a..fork/topic): count unavailable",
});
expect(output.unresolvedProblems[0]?.reason).toContain("count unavailable");
});

test("R6: fast-forward failures retain the exact git error", async () => {
const output = expectOk(
await updateWorktrees(
{ dryRun: false },
{
git: reconciliationGit({
commitCountMap: new Map([
...flatBranchesConfig([mainWt, featureA]).commitCountMap,
["feature-a..fork/topic", 1],
["fork/topic..feature-a", 0],
]),
fastForwardToRefFail: { code: "MERGE_FAILED", message: "ref moved during update" },
}),
},
),
);

expect(output.reconciliations[0]).toMatchObject({
action: "aborted",
warning: "Failed to fast-forward to tracking ref: ref moved during update",
});
expect(output.unresolvedProblems[0]?.reason).toContain("ref moved during update");
});

test("R6: dirty-state inspection failures are not reported as dirty", async () => {
const output = expectOk(
await updateWorktrees(
{ dryRun: false },
{
git: reconciliationGit({
commitCountMap: new Map([
...flatBranchesConfig([mainWt, featureA]).commitCountMap,
["feature-a..fork/topic", 1],
["fork/topic..feature-a", 0],
]),
isDirtyFail: { code: "UNKNOWN", message: "status unavailable" },
}),
},
),
);

expect(output.reconciliations[0]).toMatchObject({
action: "skipped-dirty",
warning: "Failed to inspect worktree: status unavailable",
});
expect(output.unresolvedProblems[0]).toMatchObject({
reason: "Failed to inspect worktree: status unavailable",
nextAction: "resolve the worktree inspection error, then re-run wt update",
});
});

test("R5/R6: dirty dry-run reports the block and performs no mutation", async () => {
const fastForwardToRefCalls: { worktreePath: string; ref: string }[] = [];
const git = reconciliationGit({
Expand Down Expand Up @@ -387,16 +523,18 @@ describe("updateWorktrees — feature tracking reconciliation (WTK-70)", () => {
["feature-a..fork/topic", 1],
["fork/topic..feature-a", 1],
]),
revListCherryPickMap: new Map([["fork/topic...feature-a", ["local"]]]),
revListCherryPickMap: new Map([
["fork/topic...feature-a", ["local"]],
["feature-a...fork/topic", ["remote"]],
]),
rebaseConflicts: new Set(["/repo-a"]),
rebaseAbortFail: { code: "UNKNOWN", message: "abort failed" },
});
const output = expectOk(await updateWorktrees({ dryRun: false, reconcile: "rebase" }, { git }));

expect(output.reconciliations[0]).toMatchObject({
action: "aborted",
warning: "Rebase abort failed: abort failed",
});
expect(output.reconciliations[0]).toMatchObject({ action: "aborted" });
expect(output.reconciliations[0]?.warning).toContain("Reconciliation failed: Rebase conflict");
expect(output.reconciliations[0]?.warning).toContain("rebase abort failed: abort failed");
expect(output.unresolved).toBe(true);
});
});
Expand Down
Loading
Loading