@cvr/stacked 0.4.4 → 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.4.4",
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(
@@ -4,7 +4,7 @@ import { GitService } from "../services/Git.js";
4
4
  import { GitHubService } from "../services/GitHub.js";
5
5
  import { StackService } from "../services/Stack.js";
6
6
  import { ErrorCode, StackError } from "../errors/index.js";
7
- import { success, warn, dim, confirm } from "../ui.js";
7
+ import { success, warn, dim, confirm, info } from "../ui.js";
8
8
 
9
9
  const dryRunFlag = Flag.boolean("dry-run").pipe(
10
10
  Flag.withDescription("Show what would be removed without making changes"),
@@ -73,7 +73,7 @@ export const clean = Command.make("clean", { dryRun: dryRunFlag, json: jsonFlag
73
73
  // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
74
74
  yield* Console.log(JSON.stringify({ removed: [], skipped: [] }, null, 2));
75
75
  } else {
76
- yield* Console.error("Nothing to clean");
76
+ yield* info("Nothing to clean");
77
77
  if (skippedMerged.length > 0) {
78
78
  yield* warn(
79
79
  `${skippedMerged.length} merged branch${skippedMerged.length === 1 ? "" : "es"} skipped (non-merged branches below):`,
@@ -102,6 +102,7 @@ export const clean = Command.make("clean", { dryRun: dryRunFlag, json: jsonFlag
102
102
  }
103
103
 
104
104
  const removed: string[] = [];
105
+ const failed: string[] = [];
105
106
 
106
107
  for (const { stackName, branch } of toRemove) {
107
108
  if (dryRun) {
@@ -130,9 +131,10 @@ export const clean = Command.make("clean", { dryRun: dryRunFlag, json: jsonFlag
130
131
  );
131
132
  if (deleted) {
132
133
  yield* stacks.removeBranch(branch);
133
- yield* stacks.markMergedBranches([branch]);
134
134
  removed.push(branch);
135
135
  yield* success(`Removed ${branch} from ${stackName}`);
136
+ } else {
137
+ failed.push(branch);
136
138
  }
137
139
  }
138
140
  }
@@ -140,7 +142,7 @@ export const clean = Command.make("clean", { dryRun: dryRunFlag, json: jsonFlag
140
142
  if (json) {
141
143
  const skipped = skippedMerged.map((x) => x.branch);
142
144
  // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
143
- yield* Console.log(JSON.stringify({ removed, skipped }, null, 2));
145
+ yield* Console.log(JSON.stringify({ removed, failed, skipped }, null, 2));
144
146
  } else if (dryRun) {
145
147
  yield* Console.error(
146
148
  `\n${toRemove.length} branch${toRemove.length === 1 ? "" : "es"} would be removed`,
@@ -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(
@@ -50,6 +50,7 @@ export const deleteCmd = Command.make("delete", {
50
50
 
51
51
  if (idx < stack.branches.length - 1 && !force) {
52
52
  return yield* new StackError({
53
+ code: ErrorCode.HAS_CHILDREN,
53
54
  message: `Branch "${name}" has children. Use --force to delete anyway.`,
54
55
  });
55
56
  }
@@ -37,7 +37,7 @@ export const detect = Command.make("detect", { dryRun: dryRunFlag, json: jsonFla
37
37
  const { untracked, skipped } = limitUntrackedBranches(untrackedAll, detectLimit);
38
38
 
39
39
  if (untracked.length === 0) {
40
- yield* Console.error("No untracked branches found");
40
+ yield* info("No untracked branches found");
41
41
  return;
42
42
  }
43
43
 
@@ -50,8 +50,13 @@ export const detect = Command.make("detect", { dryRun: dryRunFlag, json: jsonFla
50
50
  const childOf = new Map<string, string>();
51
51
  const unclassified: string[] = [];
52
52
 
53
+ // Include both untracked and already-tracked branches in tip resolution
54
+ // so untracked branches can discover parents that are already in stacks
55
+ const trackedBranches = [...alreadyTracked];
56
+ const allCandidatesForTips = [...untracked, ...trackedBranches];
57
+
53
58
  const tipResults = yield* Effect.forEach(
54
- untracked,
59
+ allCandidatesForTips,
55
60
  (branch) =>
56
61
  git.revParse(branch).pipe(
57
62
  Effect.map((oid) => [branch, oid] as const),
@@ -109,7 +114,7 @@ export const detect = Command.make("detect", { dryRun: dryRunFlag, json: jsonFla
109
114
  );
110
115
 
111
116
  // Build linear chains from trunk
112
- // Find branches whose parent is trunk (chain roots)
117
+ // Find branches whose parent is trunk or a tracked branch (chain roots)
113
118
  const childrenByParent = new Map<string, string[]>();
114
119
  for (const branch of untracked) {
115
120
  const parent = childOf.get(branch);
@@ -119,17 +124,48 @@ export const detect = Command.make("detect", { dryRun: dryRunFlag, json: jsonFla
119
124
  childrenByParent.set(parent, children);
120
125
  }
121
126
 
127
+ // Branches whose parent is already tracked — extend existing stacks
128
+ const adoptions = new Map<string, string[]>(); // trackedParent → [children]
129
+ for (const branch of untracked) {
130
+ const parent = childOf.get(branch);
131
+ if (parent !== undefined && alreadyTracked.has(parent)) {
132
+ const siblings = childrenByParent.get(parent) ?? [];
133
+ if (siblings.length === 1) {
134
+ const existing = adoptions.get(parent) ?? [];
135
+ // Walk the chain from this branch
136
+ const chain = [branch];
137
+ let current = branch;
138
+ while (true) {
139
+ const children = childrenByParent.get(current) ?? [];
140
+ const child = children[0];
141
+ if (children.length === 1 && child !== undefined) {
142
+ chain.push(child);
143
+ current = child;
144
+ } else break;
145
+ }
146
+ adoptions.set(parent, [...existing, ...chain]);
147
+ }
148
+ }
149
+ }
150
+
122
151
  const chains: string[][] = [];
123
152
  const roots = childrenByParent.get(trunk) ?? [];
124
153
 
154
+ // Track branches consumed by adoptions so they're not double-counted
155
+ const adoptedBranches = new Set<string>();
156
+ for (const branches of adoptions.values()) {
157
+ for (const b of branches) adoptedBranches.add(b);
158
+ }
159
+
125
160
  for (const root of roots) {
161
+ if (adoptedBranches.has(root)) continue;
126
162
  const chain = [root];
127
163
  let current = root;
128
164
 
129
165
  while (true) {
130
166
  const children = childrenByParent.get(current) ?? [];
131
167
  const child = children[0];
132
- if (children.length === 1 && child !== undefined) {
168
+ if (children.length === 1 && child !== undefined && !adoptedBranches.has(child)) {
133
169
  chain.push(child);
134
170
  current = child;
135
171
  } else {
@@ -150,16 +186,40 @@ export const detect = Command.make("detect", { dryRun: dryRunFlag, json: jsonFla
150
186
 
151
187
  if (json) {
152
188
  const stacksData = chains.map((chain) => ({ name: chain[0] ?? "", branches: chain }));
189
+ const adoptionData = [...adoptions.entries()].map(([parent, branches]) => ({
190
+ parent,
191
+ branches,
192
+ }));
153
193
  // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
154
- yield* Console.log(JSON.stringify({ stacks: stacksData, forks }, null, 2));
194
+ yield* Console.log(
195
+ JSON.stringify({ stacks: stacksData, adoptions: adoptionData, forks }, null, 2),
196
+ );
155
197
  return;
156
198
  }
157
199
 
158
- if (chains.length === 0) {
200
+ if (chains.length === 0 && adoptions.size === 0) {
159
201
  yield* info("No linear branch chains detected");
160
202
  return;
161
203
  }
162
204
 
205
+ // Adopt branches into existing stacks
206
+ for (const [parent, branches] of adoptions) {
207
+ const parentStack = yield* stacks.findBranchStack(parent);
208
+ if (parentStack === null) continue;
209
+ for (const branch of branches) {
210
+ if (dryRun) {
211
+ yield* Console.error(
212
+ `Would adopt "${branch}" into stack "${parentStack.name}" after "${parent}"`,
213
+ );
214
+ } else {
215
+ yield* stacks.addBranch(parentStack.name, branch, parent);
216
+ yield* success(
217
+ `Adopted "${branch}" into stack "${parentStack.name}" after "${parent}"`,
218
+ );
219
+ }
220
+ }
221
+ }
222
+
163
223
  for (const chain of chains) {
164
224
  const name = chain[0];
165
225
  if (name === undefined) continue;
@@ -177,9 +237,8 @@ export const detect = Command.make("detect", { dryRun: dryRunFlag, json: jsonFla
177
237
  }
178
238
 
179
239
  if (dryRun) {
180
- yield* Console.error(
181
- `\n${chains.length} stack${chains.length === 1 ? "" : "s"} would be created`,
182
- );
240
+ const total = chains.length + adoptions.size;
241
+ yield* Console.error(`\n${total} action${total === 1 ? "" : "s"} would be performed`);
183
242
  }
184
243
 
185
244
  if (forks.length > 0) {
@@ -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
  }
@@ -66,12 +66,14 @@ export const doctor = Command.make("doctor", { fix: fixFlag, json: jsonFlag }).p
66
66
  }
67
67
 
68
68
  // Check 2: all tracked branches exist in git
69
+ const allGitBranches = yield* git
70
+ .listBranches()
71
+ .pipe(Effect.catchTag("GitError", () => Effect.succeed([] as string[])));
72
+ const gitBranchSet = new Set(allGitBranches);
73
+
69
74
  for (const { name: stackName, stack } of stackEntries) {
70
75
  for (const branch of stack.branches) {
71
- const exists = yield* git
72
- .branchExists(branch)
73
- .pipe(Effect.catchTag("GitError", () => Effect.succeed(false)));
74
- if (!exists) {
76
+ if (!gitBranchSet.has(branch)) {
75
77
  if (fix) {
76
78
  yield* stacks.removeBranch(branch);
77
79
  findings.push({
@@ -107,6 +109,33 @@ export const doctor = Command.make("doctor", { fix: fixFlag, json: jsonFlag }).p
107
109
  }
108
110
  }
109
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
+
110
139
  if (json) {
111
140
  // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
112
141
  yield* Console.log(JSON.stringify({ findings }, null, 2));
@@ -99,17 +99,21 @@ export const refreshStackedPRBodies = ({
99
99
  getUserBody?: (branch: string, idx: number) => string | undefined;
100
100
  }) =>
101
101
  Effect.gen(function* () {
102
- const prEntries = yield* Effect.all(
103
- branches.map((branch) => {
102
+ const prEntries = yield* Effect.forEach(
103
+ branches,
104
+ (branch) => {
104
105
  const existing = initialPrMap?.get(branch);
105
106
  if (existing !== undefined) {
106
107
  return Effect.succeed([branch, existing] as const);
107
108
  }
108
109
  return gh.getPR(branch).pipe(Effect.map((pr) => [branch, pr] as const));
109
- }),
110
+ },
111
+ { concurrency: 5 },
110
112
  );
111
113
  const prMap = new Map(prEntries);
112
114
 
115
+ // Collect all updates, then apply in parallel
116
+ const updates: Array<{ branch: string; body: string }> = [];
113
117
  for (let i = 0; i < branches.length; i++) {
114
118
  const branch = branches[i];
115
119
  if (branch === undefined) continue;
@@ -124,8 +128,12 @@ export const refreshStackedPRBodies = ({
124
128
  getUserBody?.(branch, i),
125
129
  metadata,
126
130
  );
127
- yield* gh.updatePR({ branch, body });
131
+ updates.push({ branch, body });
128
132
  }
129
133
 
134
+ yield* Effect.forEach(updates, ({ branch, body }) => gh.updatePR({ branch, body }), {
135
+ concurrency: 5,
136
+ });
137
+
130
138
  return prMap;
131
139
  });
@@ -25,7 +25,7 @@ import { amend } from "./amend.js";
25
25
 
26
26
  const root = Command.make("stacked").pipe(
27
27
  Command.withDescription(
28
- "Branch-based stacked PR manager\n\nGlobal flags:\n --verbose Show detailed output\n --quiet, -q Suppress non-essential output\n --no-color Disable color output\n --yes, -y Skip confirmation prompts",
28
+ "Branch-based stacked PR manager\n\nGlobal flags:\n --verbose, -v Show detailed output\n --quiet, -q Suppress non-essential output\n --no-color Disable color output\n --yes, -y Skip confirmation prompts",
29
29
  ),
30
30
  Command.withExamples([
31
31
  { command: "stacked create feat-auth", description: "Create a new branch in the stack" },
@@ -1,4 +1,4 @@
1
- import { Command } from "effect/unstable/cli";
1
+ import { Command, Flag } from "effect/unstable/cli";
2
2
  import { Config, Console, Effect } from "effect";
3
3
  import { StackError } from "../errors/index.js";
4
4
  import { mkdirSync, writeFileSync } from "fs";
@@ -7,10 +7,12 @@ import { homedir } from "os";
7
7
 
8
8
  const skillContent = typeof __SKILL_CONTENT__ !== "undefined" ? __SKILL_CONTENT__ : null;
9
9
 
10
- export const init = Command.make("init").pipe(
10
+ const jsonFlag = Flag.boolean("json").pipe(Flag.withDescription("Output as JSON"));
11
+
12
+ export const init = Command.make("init", { json: jsonFlag }).pipe(
11
13
  Command.withDescription("Install the stacked Claude skill to ~/.claude/skills"),
12
14
  Command.withExamples([{ command: "stacked init", description: "Install the Claude skill" }]),
13
- Command.withHandler(() =>
15
+ Command.withHandler(({ json }) =>
14
16
  Effect.gen(function* () {
15
17
  if (skillContent === null) {
16
18
  return yield* new StackError({
@@ -24,7 +26,9 @@ export const init = Command.make("init").pipe(
24
26
  const targetDir = join(skillsDir, "stacked");
25
27
  const targetPath = join(targetDir, "SKILL.md");
26
28
 
27
- yield* Console.error(`Writing skill to ${targetPath}...`);
29
+ if (!json) {
30
+ yield* Console.error(`Writing skill to ${targetPath}...`);
31
+ }
28
32
  yield* Effect.try({
29
33
  try: () => {
30
34
  mkdirSync(targetDir, { recursive: true });
@@ -33,12 +37,17 @@ export const init = Command.make("init").pipe(
33
37
  catch: (e) => new StackError({ message: `Failed to write skill: ${e}` }),
34
38
  });
35
39
 
36
- yield* Console.error(`Installed stacked skill to ${targetPath}`);
37
- yield* Console.error("\nNext steps:");
38
- yield* Console.error(" stacked create <name> # start your first stack");
39
- yield* Console.error(
40
- " stacked trunk <name> # only if auto-detection picks the wrong trunk",
41
- );
40
+ if (json) {
41
+ // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
42
+ yield* Console.log(JSON.stringify({ path: targetPath }, null, 2));
43
+ } else {
44
+ yield* Console.error(`Installed stacked skill to ${targetPath}`);
45
+ yield* Console.error("\nNext steps:");
46
+ yield* Console.error(" stacked create <name> # start your first stack");
47
+ yield* Console.error(
48
+ " stacked trunk <name> # only if auto-detection picks the wrong trunk",
49
+ );
50
+ }
42
51
  }),
43
52
  ),
44
53
  );
@@ -28,13 +28,23 @@ export const log = Command.make("log", { json: jsonFlag }).pipe(
28
28
 
29
29
  const trunk = yield* stacks.getTrunk();
30
30
  const { branches } = result.stack;
31
+ const data = yield* stacks.load();
32
+ const mergedSet = new Set(data.mergedBranches);
33
+
34
+ const effectiveBase = (i: number): string => {
35
+ for (let j = i - 1; j >= 0; j--) {
36
+ const candidate = branches[j];
37
+ if (candidate !== undefined && !mergedSet.has(candidate)) return candidate;
38
+ }
39
+ return trunk;
40
+ };
31
41
 
32
42
  if (json) {
33
43
  const entries = [];
34
44
  for (let i = 0; i < branches.length; i++) {
35
45
  const branch = branches[i];
36
46
  if (branch === undefined) continue;
37
- const base = i === 0 ? trunk : (branches[i - 1] ?? trunk);
47
+ const base = effectiveBase(i);
38
48
  const commits = yield* git
39
49
  .log(`${base}..${branch}`, { oneline: true })
40
50
  .pipe(Effect.catchTag("GitError", () => Effect.succeed("")));
@@ -48,7 +58,7 @@ export const log = Command.make("log", { json: jsonFlag }).pipe(
48
58
  for (let i = 0; i < branches.length; i++) {
49
59
  const branch = branches[i];
50
60
  if (branch === undefined) continue;
51
- const base = i === 0 ? trunk : (branches[i - 1] ?? trunk);
61
+ const base = effectiveBase(i);
52
62
  yield* Console.log(`\n── ${branch} ──`);
53
63
  const rangeLog = yield* git
54
64
  .log(`${base}..${branch}`, { oneline: true })
@@ -32,12 +32,14 @@ export const reorder = Command.make("reorder", {
32
32
 
33
33
  if (Option.isNone(before) && Option.isNone(after)) {
34
34
  return yield* new StackError({
35
+ code: ErrorCode.USAGE_ERROR,
35
36
  message: "Specify --before or --after to indicate target position",
36
37
  });
37
38
  }
38
39
 
39
40
  if (Option.isSome(before) && Option.isSome(after)) {
40
41
  return yield* new StackError({
42
+ code: ErrorCode.USAGE_ERROR,
41
43
  message: "Specify either --before or --after, not both",
42
44
  });
43
45
  }
@@ -2,7 +2,7 @@ import { Command, Flag } from "effect/unstable/cli";
2
2
  import { Console, Effect } from "effect";
3
3
  import { GitService } from "../services/Git.js";
4
4
  import { StackService } from "../services/Stack.js";
5
- import { stdout } from "../ui.js";
5
+ import { stdout, info } from "../ui.js";
6
6
 
7
7
  const jsonFlag = Flag.boolean("json").pipe(Flag.withDescription("Output as JSON"));
8
8
 
@@ -24,7 +24,7 @@ export const stacks = Command.make("stacks", { json: jsonFlag }).pipe(
24
24
  // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
25
25
  yield* Console.log(JSON.stringify({ stacks: [] }));
26
26
  } else {
27
- yield* Console.error("No stacks");
27
+ yield* info("No stacks");
28
28
  }
29
29
  return;
30
30
  }