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/calm-branches-reconcile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"worktree-kit": minor
---

Reconcile active feature worktrees with their configured tracking refs before updating their parent stacks.
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ wt update [branch] [options]
|------|-------------|
| `--dry-run` | Show what would be done without making changes |
| `--cleanup` | Automatically clean up branches with gone remotes after update |
| `--reconcile rebase\|abort\|reset` | Resolve genuine feature/upstream divergence (`reset` requires a named branch) |

**Examples:**

Expand All @@ -256,9 +257,14 @@ wt update --cleanup
**How it works:**

1. Fetches all remotes
2. Fast-forwards the default branch (or updates its ref if no worktree exists for it)
3. Detects parent branches via merge-base
4. Rebases feature branches in correct order — parents before children
2. Reconciles each targeted feature worktree with that branch's configured Git upstream
3. Fast-forwards the default branch (or updates its ref if no worktree exists for it)
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.

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.

**Fork workflow** — when `upstream` is set in config (see `wt init --upstream`), the default branch is synced from `<upstream>/<default>` instead of `origin/<default>`, whether it is fast-forwarded in its own worktree or updated by ref because no worktree has it checked out. After a successful upstream sync, `post-update` hooks also run for the default branch (so you can, for example, push the synced default branch back to your fork); with no worktree for the default branch they run in the repository root.

Expand Down
130 changes: 130 additions & 0 deletions src/application/use-cases/update-worktrees.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,136 @@ describe("updateWorktrees", () => {
});
});

describe("updateWorktrees — feature tracking reconciliation (WTK-70)", () => {
function reconciliationGit(options: Parameters<typeof createFakeGit>[0] = {}) {
const worktrees = [mainWt, featureA];
return createFakeGit({
worktrees,
...flatBranchesConfig(worktrees),
branchUpstreams: new Map([["feature-a", "fork/topic"]]),
...options,
});
}

test("R2: remote-only advance fast-forwards the feature before its parent rebase", async () => {
const fastForwardToRefCalls: { worktreePath: string; ref: string }[] = [];
const git = reconciliationGit({
commitCountMap: new Map([
...flatBranchesConfig([mainWt, featureA]).commitCountMap,
["feature-a..fork/topic", 2],
["fork/topic..feature-a", 0],
]),
fastForwardToRefCalls,
});
const output = expectOk(await updateWorktrees({ dryRun: false }, { git }));

expect(fastForwardToRefCalls).toEqual([{ worktreePath: "/repo-a", ref: "fork/topic" }]);
expect(output.reconciliations[0]).toMatchObject({ state: "remote-only", action: "fast-forwarded" });
expect(output.unresolved).toBe(false);
});

test("R3: patch-equivalent rewrite saves a recovery ref and realigns", async () => {
const createRecoveryRefCalls: string[] = [];
const resetHardToRefCalls: { worktreePath: string; ref: string }[] = [];
const git = reconciliationGit({
commitCountMap: new Map([
...flatBranchesConfig([mainWt, featureA]).commitCountMap,
["feature-a..fork/topic", 2],
["fork/topic..feature-a", 2],
]),
revListCherryPickMap: new Map([["fork/topic...feature-a", []]]),
createRecoveryRefCalls,
resetHardToRefCalls,
});
const output = expectOk(await updateWorktrees({ dryRun: false }, { git }));

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

test("R4: genuine divergence rebases only under the selected policy", 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"]]]),
createRecoveryRefCalls: recoveryCalls,
rebaseCalls,
});
const output = expectOk(await updateWorktrees({ dryRun: false, reconcile: "rebase" }, { git }));

expect(recoveryCalls).toEqual(["feature-a"]);
expect(rebaseCalls[0]).toMatchObject({ worktreePath: "/repo-a", onto: "fork/topic" });
expect(output.reconciliations[0]).toMatchObject({ state: "diverged", action: "rebased" });
});

test("R5/R6: dirty dry-run reports the block and performs no mutation", async () => {
const fastForwardToRefCalls: { worktreePath: string; ref: string }[] = [];
const git = reconciliationGit({
commitCountMap: new Map([
...flatBranchesConfig([mainWt, featureA]).commitCountMap,
["feature-a..fork/topic", 1],
["fork/topic..feature-a", 0],
]),
dirtyWorktrees: new Set(["/repo-a"]),
fastForwardToRefCalls,
});
const output = expectOk(await updateWorktrees({ dryRun: true }, { git }));

expect(fastForwardToRefCalls).toEqual([]);
expect(output.reconciliations[0]).toMatchObject({ action: "skipped-dirty" });
expect(output.reports.find((report) => report.branch === "feature-a")?.result).toMatchObject({
status: "skipped",
reason: "remote reconciliation unresolved",
});
expect(output.unresolved).toBe(true);
});

test("R7: upstream lookup failures leave the branch unresolved", async () => {
const git = reconciliationGit({
getBranchUpstreamFail: { code: "UNKNOWN", message: "cannot read config" },
});
const output = expectOk(await updateWorktrees({ dryRun: false }, { git }));

expect(output.reconciliations[0]).toMatchObject({
branch: "feature-a",
state: "missing",
action: "aborted",
warning: "Failed to resolve tracking ref: cannot read config",
});
expect(output.reports.find((report) => report.branch === "feature-a")?.result).toMatchObject({
status: "skipped",
reason: "remote reconciliation unresolved",
});
expect(output.unresolved).toBe(true);
});

test("R7: a failed reconciliation rebase reports a failed abort", async () => {
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"]]]),
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.unresolved).toBe(true);
});
});

describe("updateWorktrees — parent detection", () => {
// main: A — B — C
// feat-a: C — D — E
Expand Down
Loading