@cvr/stacked 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/stacked CHANGED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cvr/stacked",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/cevr/stacked"
@@ -45,7 +45,7 @@ What do you need?
45
45
  | `stacked down` | Move down one branch in the stack |
46
46
  | `stacked top` | Jump to top of stack |
47
47
  | `stacked bottom` | Jump to bottom of stack |
48
- | `stacked sync` | Fetch + rebase + force-push stack on trunk (`--from`, `--dry-run`, `--json`) |
48
+ | `stacked sync` | Fetch + sync stack on trunk (`--from`, `--dry-run`, `--rebase-only`, `--json`) |
49
49
  | `stacked detect` | Detect branch chains and register as stacks (`--dry-run`, `--json`) |
50
50
  | `stacked clean` | Remove merged branches + remote branches (`--dry-run`, `--json`) |
51
51
  | `stacked delete <name>` | Remove branch from stack + git + remote (`--keep-remote`, `--force`, `--dry-run`, `--json`) |
@@ -143,23 +143,33 @@ All navigation commands support `--json` for structured output. `checkout` falls
143
143
 
144
144
  ## Syncing / Rebasing / Pushing
145
145
 
146
- Fetch latest trunk, rebase the entire stack bottom-to-top, then force-push each rebased branch (`--force-with-lease`):
146
+ Fetch latest trunk, then incrementally sync the stack bottom-to-top using fork-point tracking:
147
147
 
148
148
  ```sh
149
149
  stacked sync
150
- stacked sync --dry-run # preview rebase/push plan without executing
151
- stacked sync --json # structured output: { branches: [{ name, action, base }] }
150
+ stacked sync --dry-run # preview sync plan with predicted actions per branch
151
+ stacked sync --rebase-only # force rebase path (skip tree-merge)
152
+ stacked sync --json # structured output: { branches: [{ name, action, base }] }
152
153
  ```
153
154
 
154
- After mid-stack changes, rebase only the branches above a specific point:
155
+ **How sync works:**
156
+
157
+ 1. Fetch and rebase trunk onto `origin/trunk`
158
+ 2. For each branch, check if parent moved since last sync (`syncedOnto` metadata)
159
+ 3. If parent unchanged → skip (no push needed)
160
+ 4. If parent changed → try tree-merge fast path (es-git: `mergeTrees`, creates merge commit, no replay)
161
+ 5. If tree-merge conflicts → fall back to rebase with corrected fork-point as `oldBase`
162
+ 6. Force-push changed branches, update `syncedOnto` metadata
163
+
164
+ After mid-stack changes, sync only the branches above a specific point:
155
165
 
156
166
  ```sh
157
167
  stacked checkout feat-auth
158
168
  # ... make changes, commit ...
159
- stacked sync --from feat-auth # rebases only children of feat-auth
169
+ stacked sync --from feat-auth # syncs only children of feat-auth
160
170
  ```
161
171
 
162
- **Note:** `sync` requires a clean working tree — commit or stash before running (except with `--dry-run`). On rebase conflict, the rebase is left in progress so you can resolve it:
172
+ **Note:** `sync` requires a clean working tree — commit or stash before running (except with `--dry-run`). On rebase conflict (fallback path), the rebase is left in progress so you can resolve it:
163
173
 
164
174
  ```sh
165
175
  # On conflict:
@@ -311,10 +321,11 @@ Usage errors (invalid args, bad state) exit code 2. Operational errors (git/gh f
311
321
 
312
322
  ## Data Model
313
323
 
314
- Stack metadata lives in `.git/stacked.json`. Each branch's parent is implied by array position:
324
+ Stack metadata lives in `.git/stacked.json`. Each branch record stores:
315
325
 
316
- - `branches[0]` parent is trunk
317
- - `branches[n]` parent is `branches[n-1]`
326
+ - `stack` which stack it belongs to
327
+ - `parent` parent branch name (null for root)
328
+ - `syncedOnto` — (optional) the parent branch tip SHA at last sync, used for incremental sync
318
329
 
319
330
  Trunk is auto-detected on first use from `origin/HEAD` when available, then falls back to local `main`, `master`, or `develop`. Override with `stacked trunk <name>`.
320
331
 
@@ -356,8 +367,10 @@ stacked down # go to previous branch
356
367
  ## Gotchas
357
368
 
358
369
  - `stacked sync` requires a clean working tree — commit or stash first (except `--dry-run`)
359
- - `stacked sync` rebases bottom-to-top and force-pushes each rebased branch with lease
360
- - `stacked sync` leaves rebase in progress on conflict resolve with `git rebase --continue`, then resume with `stacked sync --from <parent>`
370
+ - `stacked sync` uses tree-merge by default (es-git); falls back to rebase on conflict or CLI backend
371
+ - `stacked sync` skips branches whose parent hasn't moved since last sync
372
+ - `stacked sync` leaves rebase in progress on conflict (fallback path) — resolve with `git rebase --continue`, then resume with `stacked sync --from <parent>`
373
+ - `syncedOnto` metadata may become stale if branches are rebased outside `stacked` — first sync after that falls back to `merge-base`
361
374
  - `stacked submit` force-pushes by default (use `--no-force` to disable)
362
375
  - `stacked submit` and `stacked clean` require `gh` CLI authenticated (`gh auth login`)
363
376
  - PRs target parent branches, not trunk — this is intentional for stacked review
@@ -369,4 +382,5 @@ stacked down # go to previous branch
369
382
  - Detached HEAD is detected and produces a clear error — checkout a branch first
370
383
  - Stack file writes are atomic (write to tmp, then rename) to prevent corruption
371
384
  - `create` and `adopt` are idempotent — safe to re-run after transient failures
372
- - Use `stacked doctor` to detect and fix metadata drift (stale branches, missing trunk)
385
+ - Use `stacked doctor` to detect and fix metadata drift (stale branches, missing trunk, stale syncedOnto)
386
+ - `stacked doctor --fix` clears stale `syncedOnto` entries pointing at non-existent commits
@@ -87,6 +87,15 @@ export const adopt = Command.make("adopt", {
87
87
  yield* stacks.addBranch(result.name, branch, afterBranch);
88
88
  }
89
89
 
90
+ // Record fork-point for incremental sync based on the branch's merge-base with its parent
91
+ const parentRef = Option.isSome(after) ? after.value : yield* git.currentBranch();
92
+ const parentTip = yield* git
93
+ .mergeBase(branch, parentRef)
94
+ .pipe(Effect.catchTag("GitError", () => git.revParse(parentRef)));
95
+ yield* stacks
96
+ .updateSyncedOnto(branch, parentTip)
97
+ .pipe(Effect.catchTag("StackError", () => Effect.void));
98
+
90
99
  if (json) {
91
100
  const stackResult = yield* stacks.currentStack();
92
101
  const stackName = stackResult?.name ?? branch;
@@ -61,34 +61,70 @@ export const amend = Command.make("amend", {
61
61
  return;
62
62
  }
63
63
 
64
- // Rebase children
64
+ // Sync children using fork-point-aware algorithm
65
65
  const children = branches.slice(idx + 1);
66
66
  const synced: string[] = [];
67
+ const data = yield* stacks.load();
68
+ const mergedSet = new Set(data.mergedBranches);
67
69
 
68
70
  yield* Effect.gen(function* () {
69
71
  for (let i = 0; i < children.length; i++) {
70
72
  const branch = children[i];
71
73
  if (branch === undefined) continue;
72
- const newBase = i === 0 ? fromBranch : (children[i - 1] ?? fromBranch);
74
+ // Compute effective base, skipping merged branches (same as sync)
75
+ let newBase = fromBranch;
76
+ for (let j = i - 1; j >= 0; j--) {
77
+ const candidate = children[j];
78
+ if (candidate !== undefined && !mergedSet.has(candidate)) {
79
+ newBase = candidate;
80
+ break;
81
+ }
82
+ }
73
83
 
74
- const oldBase = yield* git
75
- .mergeBase(branch, newBase)
76
- .pipe(Effect.catchTag("GitError", () => Effect.succeed(newBase)));
84
+ const newBaseTip = yield* git.revParse(newBase);
85
+ const branchHead = yield* git.revParse(branch);
86
+ const syncedOnto = yield* stacks.getSyncedOnto(branch);
77
87
 
78
- yield* git.checkout(branch);
79
- yield* withSpinner(
80
- `Rebasing ${branch} onto ${newBase}`,
81
- git.rebaseOnto(branch, newBase, oldBase),
82
- ).pipe(
83
- Effect.catchTag("GitError", (e) =>
84
- Effect.fail(
85
- new StackError({
86
- code: ErrorCode.REBASE_CONFLICT,
87
- message: `Rebase conflict on ${branch}: ${e.message}\n\nResolve conflicts, then run:\n git rebase --continue`,
88
- }),
88
+ // Resolve old base: prefer recorded fork-point, fall back to merge-base
89
+ const oldBase =
90
+ syncedOnto ??
91
+ (yield* git
92
+ .mergeBase(branch, newBase)
93
+ .pipe(Effect.catchTag("GitError", () => Effect.succeed(newBase))));
94
+
95
+ // Try tree-merge fast path
96
+ const mergeResult = yield* git
97
+ .treeMergeSync({
98
+ branch,
99
+ branchHead,
100
+ oldBase,
101
+ newBase: newBaseTip,
102
+ message: `sync: incorporate changes from ${newBase}`,
103
+ })
104
+ .pipe(
105
+ Effect.catchTag("GitError", () => Effect.succeed({ action: "conflict" as const })),
106
+ );
107
+
108
+ if (mergeResult.action === "merged" || mergeResult.action === "up-to-date") {
109
+ yield* stacks.updateSyncedOnto(branch, newBaseTip);
110
+ } else {
111
+ // Conflict — fall back to rebase with corrected oldBase
112
+ yield* git.checkout(branch);
113
+ yield* withSpinner(
114
+ `Rebasing ${branch} onto ${newBase}`,
115
+ git.rebaseOnto(branch, newBase, oldBase),
116
+ ).pipe(
117
+ Effect.catchTag("GitError", (e) =>
118
+ Effect.fail(
119
+ new StackError({
120
+ code: ErrorCode.REBASE_CONFLICT,
121
+ message: `Rebase conflict on ${branch}: ${e.message}\n\nResolve conflicts, then run:\n git rebase --continue`,
122
+ }),
123
+ ),
89
124
  ),
90
- ),
91
- );
125
+ );
126
+ yield* stacks.updateSyncedOnto(branch, newBaseTip);
127
+ }
92
128
  synced.push(branch);
93
129
  }
94
130
  }).pipe(
@@ -99,6 +99,10 @@ export const create = Command.make("create", {
99
99
  yield* stacks.addBranch(stackName, name, baseBranch === trunk ? undefined : baseBranch);
100
100
  }
101
101
 
102
+ // Record the fork-point for incremental sync
103
+ const baseTip = yield* git.revParse(baseBranch);
104
+ yield* stacks.updateSyncedOnto(name, baseTip);
105
+
102
106
  if (json) {
103
107
  // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
104
108
  yield* Console.log(
@@ -8,7 +8,7 @@ const fixFlag = Flag.boolean("fix").pipe(Flag.withDescription("Auto-fix issues w
8
8
  const jsonFlag = Flag.boolean("json").pipe(Flag.withDescription("Output as JSON"));
9
9
 
10
10
  interface Finding {
11
- type: "stale_branch" | "missing_trunk" | "duplicate_branch" | "parse_error";
11
+ type: "stale_branch" | "missing_trunk" | "duplicate_branch" | "stale_fork_point" | "parse_error";
12
12
  message: string;
13
13
  fixed: boolean;
14
14
  }
@@ -109,6 +109,33 @@ export const doctor = Command.make("doctor", { fix: fixFlag, json: jsonFlag }).p
109
109
  }
110
110
  }
111
111
 
112
+ // Check 4: syncedOnto entries point at valid commits
113
+ for (const [branch, record] of Object.entries(data.branches)) {
114
+ if (record.syncedOnto == null) continue;
115
+ const valid = yield* git.revParse(record.syncedOnto).pipe(
116
+ Effect.as(true),
117
+ Effect.catchTag("GitError", () => Effect.succeed(false)),
118
+ );
119
+ if (!valid) {
120
+ if (fix) {
121
+ yield* stacks
122
+ .updateSyncedOnto(branch, null)
123
+ .pipe(Effect.catchTag("StackError", () => Effect.void));
124
+ findings.push({
125
+ type: "stale_fork_point",
126
+ message: `Cleared stale syncedOnto for "${branch}" (commit ${record.syncedOnto.slice(0, 7)} no longer exists)`,
127
+ fixed: true,
128
+ });
129
+ } else {
130
+ findings.push({
131
+ type: "stale_fork_point",
132
+ message: `Branch "${branch}" has stale syncedOnto (commit ${record.syncedOnto.slice(0, 7)} no longer exists)`,
133
+ fixed: false,
134
+ });
135
+ }
136
+ }
137
+ }
138
+
112
139
  if (json) {
113
140
  // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
114
141
  yield* Console.log(JSON.stringify({ findings }, null, 2));
@@ -21,10 +21,13 @@ const jsonFlag = Flag.boolean("json").pipe(Flag.withDescription("Output as JSON"
21
21
  const dryRunFlag = Flag.boolean("dry-run").pipe(
22
22
  Flag.withDescription("Show rebase plan without executing"),
23
23
  );
24
+ const rebaseOnlyFlag = Flag.boolean("rebase-only").pipe(
25
+ Flag.withDescription("Force rebase path (skip tree-merge)"),
26
+ );
24
27
 
25
28
  interface SyncResult {
26
29
  name: string;
27
- action: "rebased" | "skipped" | "up-to-date";
30
+ action: "rebased" | "merged" | "skipped" | "up-to-date";
28
31
  base: string;
29
32
  }
30
33
 
@@ -33,6 +36,7 @@ export const sync = Command.make("sync", {
33
36
  from: fromFlag,
34
37
  json: jsonFlag,
35
38
  dryRun: dryRunFlag,
39
+ rebaseOnly: rebaseOnlyFlag,
36
40
  }).pipe(
37
41
  Command.withDescription(
38
42
  "Fetch, rebase, and force-push stack branches. Use --from to start from a branch.",
@@ -41,8 +45,9 @@ export const sync = Command.make("sync", {
41
45
  { command: "stacked sync", description: "Sync local trunk, then rebase entire stack on trunk" },
42
46
  { command: "stacked sync --from feat-auth", description: "Resume from a specific branch" },
43
47
  { command: "stacked sync --dry-run", description: "Preview rebase plan" },
48
+ { command: "stacked sync --rebase-only", description: "Force rebase (skip tree-merge)" },
44
49
  ]),
45
- Command.withHandler(({ trunk: trunkOpt, from: fromOpt, json, dryRun }) =>
50
+ Command.withHandler(({ trunk: trunkOpt, from: fromOpt, json, dryRun, rebaseOnly }) =>
46
51
  Effect.gen(function* () {
47
52
  const git = yield* GitService;
48
53
  const gh = yield* GitHubService;
@@ -105,18 +110,43 @@ export const sync = Command.make("sync", {
105
110
  const results: SyncResult[] = [];
106
111
 
107
112
  if (dryRun) {
108
- results.push({ name: trunk, action: "skipped", base: originTrunk });
113
+ results.push({ name: trunk, action: "rebased", base: originTrunk });
109
114
  if (!json) {
110
- yield* Console.error(`Would fetch and rebase ${trunk} onto ${originTrunk}`);
115
+ yield* Console.error(`${trunk}: rebase onto ${originTrunk}`);
111
116
  }
112
117
 
113
118
  for (let i = startIdx; i < branches.length; i++) {
114
119
  const branch = branches[i];
115
120
  if (branch === undefined) continue;
116
121
  const base = effectiveBase(i, originTrunk);
117
- results.push({ name: branch, action: "skipped", base });
122
+
123
+ const newBaseTip = yield* git.revParse(base);
124
+ const branchHead = yield* git.revParse(branch);
125
+ const syncedOnto = yield* stacks.getSyncedOnto(branch);
126
+
127
+ let action: SyncResult["action"];
128
+ if (syncedOnto !== null && syncedOnto === newBaseTip) {
129
+ action = "up-to-date";
130
+ } else {
131
+ const alreadyIncorporated = yield* git
132
+ .isAncestor(newBaseTip, branchHead)
133
+ .pipe(Effect.catchTag("GitError", () => Effect.succeed(false)));
134
+ if (alreadyIncorporated) {
135
+ action = "up-to-date";
136
+ } else {
137
+ action = !rebaseOnly && git.supportsTreeMerge() ? "merged" : "rebased";
138
+ }
139
+ }
140
+
141
+ results.push({ name: branch, action, base });
118
142
  if (!json) {
119
- yield* Console.error(`Would rebase and force-push ${branch} onto ${base}`);
143
+ const verb =
144
+ action === "up-to-date"
145
+ ? "up-to-date"
146
+ : action === "merged"
147
+ ? `tree-merge onto ${base}`
148
+ : `rebase onto ${base}`;
149
+ yield* Console.error(`${branch}: ${verb}`);
120
150
  }
121
151
  }
122
152
 
@@ -124,9 +154,12 @@ export const sync = Command.make("sync", {
124
154
  // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
125
155
  yield* Console.log(JSON.stringify({ branches: results }, null, 2));
126
156
  } else {
127
- yield* Console.error(
128
- `\n${results.length} branch${results.length === 1 ? "" : "es"} would be rebased`,
129
- );
157
+ const changed = results.filter((r) => r.action !== "up-to-date").length;
158
+ const skipped = results.filter((r) => r.action === "up-to-date").length;
159
+ const parts: string[] = [];
160
+ if (changed > 0) parts.push(`${changed} to sync`);
161
+ if (skipped > 0) parts.push(`${skipped} up-to-date`);
162
+ yield* Console.error(`\n${parts.join(", ")}`);
130
163
  }
131
164
  return;
132
165
  }
@@ -151,29 +184,79 @@ export const sync = Command.make("sync", {
151
184
  if (branch === undefined) continue;
152
185
  const newBase = effectiveBase(i, originTrunk);
153
186
 
154
- // Compute old base (merge-base of this branch and its parent) before rebasing
155
- const oldBase = yield* git
156
- .mergeBase(branch, newBase)
157
- .pipe(Effect.catchTag("GitError", () => Effect.succeed(newBase)));
158
-
159
- yield* git.checkout(branch);
160
- yield* withSpinner(
161
- `Rebasing ${branch} onto ${newBase}`,
162
- git.rebaseOnto(branch, newBase, oldBase),
163
- ).pipe(
164
- Effect.catchTag("GitError", (e) => {
165
- const hint =
166
- i === 0 ? "stacked sync" : `stacked sync --from ${branches[i - 1] ?? trunk}`;
167
- return Effect.fail(
168
- new StackError({
169
- code: ErrorCode.REBASE_CONFLICT,
170
- message: `Rebase conflict on ${branch}: ${e.message}\n\nResolve conflicts, then run:\n git rebase --continue\n ${hint}`,
171
- }),
172
- );
173
- }),
174
- );
175
- yield* withSpinner(`Pushing ${branch}`, git.push(branch, { force: true }));
176
- results.push({ name: branch, action: "rebased", base: newBase });
187
+ const newBaseTip = yield* git.revParse(newBase);
188
+ const branchHead = yield* git.revParse(branch);
189
+ const syncedOnto = yield* stacks.getSyncedOnto(branch);
190
+
191
+ // Skip if parent hasn't moved since last sync
192
+ if (syncedOnto !== null && syncedOnto === newBaseTip) {
193
+ results.push({ name: branch, action: "up-to-date", base: newBase });
194
+ continue;
195
+ }
196
+
197
+ // Skip if parent is already an ancestor of branch (manually synced)
198
+ const alreadyIncorporated = yield* git
199
+ .isAncestor(newBaseTip, branchHead)
200
+ .pipe(Effect.catchTag("GitError", () => Effect.succeed(false)));
201
+ if (alreadyIncorporated) {
202
+ yield* stacks.updateSyncedOnto(branch, newBaseTip);
203
+ results.push({ name: branch, action: "up-to-date", base: newBase });
204
+ continue;
205
+ }
206
+
207
+ // Resolve old base: prefer recorded fork-point, fall back to merge-base
208
+ const oldBase =
209
+ syncedOnto ??
210
+ (yield* git
211
+ .mergeBase(branch, newBase)
212
+ .pipe(Effect.catchTag("GitError", () => Effect.succeed(newBase))));
213
+
214
+ // Try tree-merge fast path first (unless --rebase-only)
215
+ const mergeResult = rebaseOnly
216
+ ? ({ action: "conflict" } as const)
217
+ : yield* git
218
+ .treeMergeSync({
219
+ branch,
220
+ branchHead,
221
+ oldBase,
222
+ newBase: newBaseTip,
223
+ message: `sync: incorporate changes from ${newBase}`,
224
+ })
225
+ .pipe(
226
+ Effect.catchTag("GitError", () =>
227
+ Effect.succeed({ action: "conflict" as const }),
228
+ ),
229
+ );
230
+
231
+ if (mergeResult.action === "merged") {
232
+ yield* stacks.updateSyncedOnto(branch, newBaseTip);
233
+ yield* withSpinner(`Pushing ${branch}`, git.push(branch, { force: true }));
234
+ results.push({ name: branch, action: "merged", base: newBase });
235
+ } else if (mergeResult.action === "up-to-date") {
236
+ yield* stacks.updateSyncedOnto(branch, newBaseTip);
237
+ results.push({ name: branch, action: "up-to-date", base: newBase });
238
+ } else {
239
+ // Conflict or unsupported backend — fall back to rebase with corrected oldBase
240
+ yield* git.checkout(branch);
241
+ yield* withSpinner(
242
+ `Rebasing ${branch} onto ${newBase}`,
243
+ git.rebaseOnto(branch, newBase, oldBase),
244
+ ).pipe(
245
+ Effect.catchTag("GitError", (e) => {
246
+ const hint =
247
+ i === 0 ? "stacked sync" : `stacked sync --from ${branches[i - 1] ?? trunk}`;
248
+ return Effect.fail(
249
+ new StackError({
250
+ code: ErrorCode.REBASE_CONFLICT,
251
+ message: `Rebase conflict on ${branch}: ${e.message}\n\nResolve conflicts, then run:\n git rebase --continue\n ${hint}`,
252
+ }),
253
+ );
254
+ }),
255
+ );
256
+ yield* stacks.updateSyncedOnto(branch, newBaseTip);
257
+ yield* withSpinner(`Pushing ${branch}`, git.push(branch, { force: true }));
258
+ results.push({ name: branch, action: "rebased", base: newBase });
259
+ }
177
260
  }
178
261
  }).pipe(
179
262
  Effect.ensuring(
@@ -37,6 +37,14 @@ export class GitService extends ServiceMap.Service<
37
37
  readonly commitAmend: (options?: { edit?: boolean }) => Effect.Effect<void, GitError>;
38
38
  readonly fetch: (remote?: string) => Effect.Effect<void, GitError>;
39
39
  readonly deleteRemoteBranch: (branch: string) => Effect.Effect<void, GitError>;
40
+ readonly treeMergeSync: (opts: {
41
+ branch: string;
42
+ branchHead: string;
43
+ oldBase: string;
44
+ newBase: string;
45
+ message: string;
46
+ }) => Effect.Effect<{ action: "merged" | "up-to-date" | "conflict" }, GitError>;
47
+ readonly supportsTreeMerge: () => boolean;
40
48
  }
41
49
  >()("@cvr/stacked/services/Git/GitService") {
42
50
  static layer: Layer.Layer<GitService> = Layer.sync(GitService, () => {
@@ -223,6 +231,9 @@ export class GitService extends ServiceMap.Service<
223
231
 
224
232
  deleteRemoteBranch: (branch) =>
225
233
  run(["push", "origin", "--delete", branch]).pipe(Effect.asVoid),
234
+
235
+ treeMergeSync: () => Effect.succeed({ action: "conflict" as const }),
236
+ supportsTreeMerge: () => false,
226
237
  };
227
238
  });
228
239
 
@@ -249,6 +260,8 @@ export class GitService extends ServiceMap.Service<
249
260
  commitAmend: () => Effect.void,
250
261
  fetch: () => Effect.void,
251
262
  deleteRemoteBranch: () => Effect.void,
263
+ treeMergeSync: () => Effect.succeed({ action: "conflict" as const }),
264
+ supportsTreeMerge: () => false,
252
265
  ...impl,
253
266
  });
254
267
  }
@@ -359,6 +359,43 @@ export const GitEsLayer = Layer.effect(
359
359
  },
360
360
  catch: (error) => makeGitError(`es-git.deleteRemoteBranch ${branch}`, error),
361
361
  }).pipe(Effect.asVoid),
362
+
363
+ treeMergeSync: ({ branch, oldBase, newBase }) =>
364
+ Effect.try({
365
+ try: () => {
366
+ const oldBaseOid = resolveOid(repo, oldBase);
367
+ const newBaseOid = resolveOid(repo, newBase);
368
+ const branchRef = refName(branch);
369
+
370
+ // Pre-flight: check for conflicts via mergeTrees
371
+ const oldBaseTree = repo.getCommit(oldBaseOid).tree();
372
+ const newBaseTree = repo.getCommit(newBaseOid).tree();
373
+ const branchHeadOid = repo.getReference(branchRef).resolve().target();
374
+ if (branchHeadOid === null) {
375
+ return { action: "conflict" as const };
376
+ }
377
+ const branchTree = repo.getCommit(branchHeadOid).tree();
378
+
379
+ const index = repo.mergeTrees(oldBaseTree, newBaseTree, branchTree);
380
+ if (index.hasConflicts()) {
381
+ return { action: "conflict" as const };
382
+ }
383
+
384
+ // Check if anything actually changed
385
+ if (newBaseOid === oldBaseOid) {
386
+ return { action: "up-to-date" as const };
387
+ }
388
+
389
+ // Clean merge — execute via rebase (which handles tree writing properly)
390
+ performRebase(repo, branchRef, oldBaseOid, newBaseOid);
391
+
392
+ return { action: "merged" as const };
393
+ },
394
+ catch: (error) =>
395
+ makeGitError(`es-git.treeMergeSync ${branch} ${oldBase} ${newBase}`, error),
396
+ }),
397
+
398
+ supportsTreeMerge: () => true,
362
399
  };
363
400
  }),
364
401
  );
@@ -22,6 +22,7 @@ const StackRecordSchema = Schema.Struct({
22
22
  const BranchRecordSchema = Schema.Struct({
23
23
  stack: Schema.String,
24
24
  parent: Schema.NullOr(Schema.String),
25
+ syncedOnto: Schema.optional(Schema.NullOr(Schema.String)),
25
26
  });
26
27
 
27
28
  const StackFileV2Schema = Schema.Struct({
@@ -54,7 +55,14 @@ interface CanonicalStackFile {
54
55
  readonly trunk: string;
55
56
  readonly stacks: Readonly<Record<string, { readonly root: string }>>;
56
57
  readonly branches: Readonly<
57
- Record<string, { readonly stack: string; readonly parent: string | null }>
58
+ Record<
59
+ string,
60
+ {
61
+ readonly stack: string;
62
+ readonly parent: string | null;
63
+ readonly syncedOnto?: string | null | undefined;
64
+ }
65
+ >
58
66
  >;
59
67
  readonly mergedBranches: readonly string[];
60
68
  }
@@ -224,7 +232,10 @@ const rewriteStackBranches = (
224
232
  stackName: string,
225
233
  branches: readonly string[],
226
234
  ): CanonicalStackFile => {
227
- const nextBranches: Record<string, { stack: string; parent: string | null }> = {
235
+ const nextBranches: Record<
236
+ string,
237
+ { stack: string; parent: string | null; syncedOnto?: string | null }
238
+ > = {
228
239
  ...data.branches,
229
240
  };
230
241
 
@@ -237,9 +248,11 @@ const rewriteStackBranches = (
237
248
  for (let i = 0; i < branches.length; i++) {
238
249
  const branch = branches[i];
239
250
  if (branch === undefined) continue;
251
+ const existing = data.branches[branch];
240
252
  nextBranches[branch] = {
241
253
  stack: stackName,
242
254
  parent: i === 0 ? null : (branches[i - 1] ?? null),
255
+ ...(existing?.syncedOnto != null ? { syncedOnto: existing.syncedOnto } : {}),
243
256
  };
244
257
  }
245
258
 
@@ -274,7 +287,10 @@ const renameStackRefs = (
274
287
  if (stackRecord === undefined) return data;
275
288
 
276
289
  const { [oldName]: _, ...restStacks } = data.stacks;
277
- const branches: Record<string, { stack: string; parent: string | null }> = {};
290
+ const branches: Record<
291
+ string,
292
+ { stack: string; parent: string | null; syncedOnto?: string | null }
293
+ > = {};
278
294
 
279
295
  for (const [branch, record] of Object.entries(data.branches)) {
280
296
  branches[branch] = {
@@ -584,6 +600,33 @@ const makeStackService = ({
584
600
  });
585
601
  }),
586
602
 
603
+ getSyncedOnto: Effect.fn("StackService.getSyncedOnto")(function* (branch: string) {
604
+ const data = yield* loadData();
605
+ const record = data.branches[branch];
606
+ if (record === undefined) return null;
607
+ return record.syncedOnto ?? null;
608
+ }),
609
+
610
+ updateSyncedOnto: Effect.fn("StackService.updateSyncedOnto")(function* (
611
+ branch: string,
612
+ oid: string | null,
613
+ ) {
614
+ const data = yield* loadData();
615
+ const record = data.branches[branch];
616
+ if (record === undefined) {
617
+ return yield* new StackError({
618
+ message: `Branch "${branch}" not found in stack metadata`,
619
+ });
620
+ }
621
+ yield* saveData({
622
+ ...data,
623
+ branches: {
624
+ ...data.branches,
625
+ [branch]: { ...record, syncedOnto: oid },
626
+ },
627
+ });
628
+ }),
629
+
587
630
  getTrunk: Effect.fn("StackService.getTrunk")(function* () {
588
631
  const data = yield* loadData();
589
632
  return data.trunk;
@@ -630,6 +673,11 @@ export class StackService extends ServiceMap.Service<
630
673
  branch: string,
631
674
  ) => Effect.Effect<{ name: string; stack: Stack } | null, StackError>;
632
675
  readonly detectTrunkCandidate: () => Effect.Effect<Option.Option<string>, never>;
676
+ readonly getSyncedOnto: (branch: string) => Effect.Effect<string | null, StackError>;
677
+ readonly updateSyncedOnto: (
678
+ branch: string,
679
+ oid: string | null,
680
+ ) => Effect.Effect<void, StackError>;
633
681
  readonly getTrunk: () => Effect.Effect<string, StackError>;
634
682
  readonly setTrunk: (name: string) => Effect.Effect<void, StackError>;
635
683
  }