@cvr/stacked 0.4.4 → 0.5.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.5.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/cevr/stacked"
@@ -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`,
@@ -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) {
@@ -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({
@@ -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
  }
@@ -90,6 +90,17 @@ export const submit = Command.make("submit", {
90
90
  const trunk = yield* stacks.getTrunk();
91
91
  const currentBranch = yield* git.currentBranch();
92
92
  const { branches } = result.stack;
93
+ const data = yield* stacks.load();
94
+ const mergedSet = new Set(data.mergedBranches);
95
+
96
+ // Compute the effective base for a branch at index i, skipping merged branches
97
+ const effectiveBase = (i: number): string => {
98
+ for (let j = i - 1; j >= 0; j--) {
99
+ const candidate = branches[j];
100
+ if (candidate !== undefined && !mergedSet.has(candidate)) return candidate;
101
+ }
102
+ return trunk;
103
+ };
93
104
 
94
105
  const rawTitle = Option.isSome(titleOpt) ? titleOpt.value : undefined;
95
106
  const rawBody = Option.isSome(bodyOpt) ? bodyOpt.value : undefined;
@@ -106,11 +117,13 @@ export const submit = Command.make("submit", {
106
117
 
107
118
  if (titles !== undefined && titles.length !== branches.length) {
108
119
  return yield* new StackError({
120
+ code: ErrorCode.USAGE_ERROR,
109
121
  message: `--title has ${titles.length} values but stack has ${branches.length} branches`,
110
122
  });
111
123
  }
112
124
  if (bodies !== undefined && bodies.length !== branches.length) {
113
125
  return yield* new StackError({
126
+ code: ErrorCode.USAGE_ERROR,
114
127
  message: `--body has ${bodies.length} values but stack has ${branches.length} branches`,
115
128
  });
116
129
  }
@@ -132,19 +145,34 @@ export const submit = Command.make("submit", {
132
145
  const results: SubmitResult[] = [];
133
146
  const prMap = new Map<
134
147
  string,
135
- { number: number; url: string; state: string; body?: string | null } | null
148
+ { number: number; url: string; state: string; base: string; body?: string | null } | null
136
149
  >();
137
150
 
151
+ // Pre-fetch all PR statuses in parallel
152
+ const activeBranches = only ? branches.filter((b) => b === currentBranch) : [...branches];
153
+ const prResults = yield* Effect.forEach(
154
+ activeBranches,
155
+ (branch) =>
156
+ gh.getPR(branch).pipe(
157
+ Effect.map((pr) => [branch, pr] as const),
158
+ Effect.catchTag("GitHubError", () => Effect.succeed([branch, null] as const)),
159
+ ),
160
+ { concurrency: 5 },
161
+ );
162
+ for (const [branch, pr] of prResults) {
163
+ prMap.set(branch, pr);
164
+ }
165
+
138
166
  for (let i = 0; i < branches.length; i++) {
139
167
  const branch = branches[i];
140
168
  if (branch === undefined) continue;
141
- const base = i === 0 ? trunk : (branches[i - 1] ?? trunk);
169
+ const base = effectiveBase(i);
142
170
 
143
171
  // --only: skip branches that aren't current
144
172
  if (only && branch !== currentBranch) continue;
145
173
 
146
174
  if (dryRun) {
147
- const existingPR = yield* gh.getPR(branch);
175
+ const existingPR = prMap.get(branch) ?? null;
148
176
  const action =
149
177
  existingPR === null
150
178
  ? "would-create"
@@ -166,8 +194,7 @@ export const submit = Command.make("submit", {
166
194
 
167
195
  yield* withSpinner(`Pushing ${branch}`, git.push(branch, { force: !noForce }));
168
196
 
169
- const existingPR = yield* gh.getPR(branch);
170
- prMap.set(branch, existingPR);
197
+ const existingPR = prMap.get(branch) ?? null;
171
198
 
172
199
  if (existingPR !== null) {
173
200
  if (existingPR.base !== base) {
@@ -205,7 +232,7 @@ export const submit = Command.make("submit", {
205
232
  body,
206
233
  draft,
207
234
  });
208
- prMap.set(branch, { number: pr.number, url: pr.url, state: "OPEN" });
235
+ prMap.set(branch, { number: pr.number, url: pr.url, state: "OPEN", base });
209
236
  yield* success(`Created PR #${pr.number}: ${pr.url}`);
210
237
  results.push({
211
238
  branch,
@@ -72,6 +72,18 @@ export const sync = Command.make("sync", {
72
72
  }
73
73
 
74
74
  const { branches } = result.stack;
75
+ const data = yield* stacks.load();
76
+ const mergedSet = new Set(data.mergedBranches);
77
+
78
+ // Compute the effective base for a branch at index i, skipping merged branches
79
+ const effectiveBase = (i: number, fallback: string): string => {
80
+ for (let j = i - 1; j >= 0; j--) {
81
+ const candidate = branches[j];
82
+ if (candidate !== undefined && !mergedSet.has(candidate)) return candidate;
83
+ }
84
+ return fallback;
85
+ };
86
+
75
87
  const fromBranch = Option.isSome(fromOpt) ? fromOpt.value : undefined;
76
88
 
77
89
  let startIdx = 0;
@@ -101,7 +113,7 @@ export const sync = Command.make("sync", {
101
113
  for (let i = startIdx; i < branches.length; i++) {
102
114
  const branch = branches[i];
103
115
  if (branch === undefined) continue;
104
- const base = i === 0 ? originTrunk : (branches[i - 1] ?? originTrunk);
116
+ const base = effectiveBase(i, originTrunk);
105
117
  results.push({ name: branch, action: "skipped", base });
106
118
  if (!json) {
107
119
  yield* Console.error(`Would rebase and force-push ${branch} onto ${base}`);
@@ -137,7 +149,7 @@ export const sync = Command.make("sync", {
137
149
  for (let i = startIdx; i < branches.length; i++) {
138
150
  const branch = branches[i];
139
151
  if (branch === undefined) continue;
140
- const newBase = i === 0 ? originTrunk : (branches[i - 1] ?? originTrunk);
152
+ const newBase = effectiveBase(i, originTrunk);
141
153
 
142
154
  // Compute old base (merge-base of this branch and its parent) before rebasing
143
155
  const oldBase = yield* git
@@ -19,6 +19,8 @@ export const ErrorCode = {
19
19
  STACK_EMPTY: "STACK_EMPTY",
20
20
  TRUNK_ERROR: "TRUNK_ERROR",
21
21
  STACK_EXISTS: "STACK_EXISTS",
22
+ USAGE_ERROR: "USAGE_ERROR",
23
+ HAS_CHILDREN: "HAS_CHILDREN",
22
24
  } as const;
23
25
 
24
26
  export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
package/src/main.ts CHANGED
@@ -15,11 +15,11 @@ const version = typeof __VERSION__ !== "undefined" ? __VERSION__ : "dev";
15
15
  // Global Flags (parsed before CLI framework, stripped from argv)
16
16
  // ============================================================================
17
17
 
18
- const globalFlags = new Set(["--verbose", "--quiet", "-q", "--no-color", "--yes", "-y"]);
18
+ const globalFlags = new Set(["--verbose", "-v", "--quiet", "-q", "--no-color", "--yes", "-y"]);
19
19
  const flagArgs = new Set(process.argv.filter((a) => globalFlags.has(a)));
20
20
  process.argv = process.argv.filter((a) => !globalFlags.has(a));
21
21
 
22
- const isVerbose = flagArgs.has("--verbose");
22
+ const isVerbose = flagArgs.has("--verbose") || flagArgs.has("-v");
23
23
  const isQuiet = flagArgs.has("--quiet") || flagArgs.has("-q");
24
24
  const isNoColor = flagArgs.has("--no-color");
25
25
  const isYes = flagArgs.has("--yes") || flagArgs.has("-y");
@@ -53,16 +53,26 @@ const usageCodes = new Set([
53
53
  "ALREADY_AT_BOTTOM",
54
54
  "TRUNK_ERROR",
55
55
  "STACK_EXISTS",
56
+ "USAGE_ERROR",
57
+ "HAS_CHILDREN",
56
58
  ]);
57
59
 
58
- const handleKnownError = (e: { message: string; code?: string | undefined }) =>
59
- Console.error(e.code !== undefined ? `Error [${e.code}]: ${e.message}` : e.message).pipe(
60
+ const isJson = process.argv.includes("--json");
61
+
62
+ const handleKnownError = (e: { message: string; code?: string | undefined }) => {
63
+ const exitCode = e.code !== undefined && usageCodes.has(e.code) ? 2 : 1;
64
+ const errorOutput = isJson
65
+ ? Console.log(JSON.stringify({ error: { code: e.code ?? null, message: e.message } }))
66
+ : Console.error(e.code !== undefined ? `Error [${e.code}]: ${e.message}` : e.message);
67
+
68
+ return errorOutput.pipe(
60
69
  Effect.andThen(
61
70
  Effect.sync(() => {
62
- process.exitCode = e.code !== undefined && usageCodes.has(e.code) ? 2 : 1;
71
+ process.exitCode = exitCode;
63
72
  }),
64
73
  ),
65
74
  );
75
+ };
66
76
 
67
77
  // @effect-diagnostics-next-line effect/strictEffectProvide:off
68
78
  BunRuntime.runMain(
@@ -121,7 +121,10 @@ export class GitService extends ServiceMap.Service<
121
121
 
122
122
  remoteDefaultBranch: (remote = "origin") =>
123
123
  run(["symbolic-ref", "--quiet", "--short", `refs/remotes/${remote}/HEAD`]).pipe(
124
- Effect.map((ref) => Option.some(ref.replace(new RegExp(`^${remote}/`), ""))),
124
+ Effect.map((ref) => {
125
+ const prefix = `${remote}/`;
126
+ return Option.some(ref.startsWith(prefix) ? ref.slice(prefix.length) : ref);
127
+ }),
125
128
  Effect.catchTag("GitError", () => Effect.succeed(Option.none())),
126
129
  ),
127
130
 
@@ -192,9 +195,28 @@ export class GitService extends ServiceMap.Service<
192
195
  ),
193
196
 
194
197
  commitAmend: (options) => {
195
- const args = ["commit", "--amend"];
196
- if (options?.edit !== true) args.push("--no-edit");
197
- return run(args).pipe(Effect.asVoid);
198
+ if (options?.edit === true) {
199
+ // Interactive editor needs inherited stdio, not piped
200
+ return Effect.tryPromise({
201
+ try: async () => {
202
+ const proc = Bun.spawn(["git", "commit", "--amend"], {
203
+ stdin: "inherit",
204
+ stdout: "inherit",
205
+ stderr: "inherit",
206
+ });
207
+ const exitCode = await proc.exited;
208
+ if (exitCode !== 0) {
209
+ throw new Error(`git commit --amend failed with exit code ${exitCode}`);
210
+ }
211
+ },
212
+ catch: (e) =>
213
+ new GitError({
214
+ message: `Process failed: ${e}`,
215
+ command: "git commit --amend",
216
+ }),
217
+ }).pipe(Effect.asVoid);
218
+ }
219
+ return run(["commit", "--amend", "--no-edit"]).pipe(Effect.asVoid);
198
220
  },
199
221
 
200
222
  fetch: (remote) => run(["fetch", remote ?? "origin"]).pipe(Effect.asVoid),
@@ -111,7 +111,8 @@ export const GitEsLayer = Layer.effect(
111
111
  const ref = repo.findReference(`refs/remotes/${remote}/HEAD`);
112
112
  if (ref === null) return Option.none();
113
113
  const target = ref.resolve().shorthand();
114
- return Option.some(target.replace(new RegExp(`^${remote}/`), ""));
114
+ const prefix = `${remote}/`;
115
+ return Option.some(target.startsWith(prefix) ? target.slice(prefix.length) : target);
115
116
  },
116
117
  catch: (error) => makeGitError(`es-git.remoteDefaultBranch ${remote}`, error),
117
118
  }).pipe(Effect.catchTag("GitError", () => Effect.succeed(Option.none()))),
@@ -127,13 +128,32 @@ export const GitEsLayer = Layer.effect(
127
128
  catch: (error) => makeGitError(`es-git.createBranch ${name}`, error),
128
129
  }).pipe(Effect.asVoid),
129
130
 
130
- deleteBranch: (name) =>
131
- Effect.try({
131
+ deleteBranch: (name, force) => {
132
+ // When force is false/undefined, shell out to `git branch -d` so git
133
+ // enforces "must be fully merged" check. es-git has no equivalent.
134
+ if (force !== true) {
135
+ return Effect.tryPromise({
136
+ try: async () => {
137
+ const proc = Bun.spawn(["git", "branch", "-d", "--", name], {
138
+ stdout: "pipe",
139
+ stderr: "pipe",
140
+ });
141
+ const exitCode = await proc.exited;
142
+ if (exitCode !== 0) {
143
+ const stderr = await new Response(proc.stderr).text();
144
+ throw new Error(stderr.trim() || `git branch -d failed with exit code ${exitCode}`);
145
+ }
146
+ },
147
+ catch: (error) => makeGitError(`es-git.deleteBranch ${name}`, error),
148
+ }).pipe(Effect.asVoid);
149
+ }
150
+ return Effect.try({
132
151
  try: () => {
133
152
  repo.getBranch(name, "Local").delete();
134
153
  },
135
154
  catch: (error) => makeGitError(`es-git.deleteBranch ${name}`, error),
136
- }).pipe(Effect.asVoid),
155
+ }).pipe(Effect.asVoid);
156
+ },
137
157
 
138
158
  checkout: (name) =>
139
159
  Effect.try({
@@ -177,17 +197,34 @@ export const GitEsLayer = Layer.effect(
177
197
  catch: (error) => makeGitError("es-git.rebaseAbort", error),
178
198
  }).pipe(Effect.asVoid),
179
199
 
180
- push: (branch, options) =>
181
- Effect.tryPromise({
200
+ push: (branch, options) => {
201
+ // es-git's protocol has no --force-with-lease equivalent; shell out to git CLI
202
+ if (options?.force === true) {
203
+ return Effect.tryPromise({
204
+ try: async () => {
205
+ const proc = Bun.spawn(
206
+ ["git", "push", "--force-with-lease", "-u", "origin", branch],
207
+ { stdout: "pipe", stderr: "pipe" },
208
+ );
209
+ const exitCode = await proc.exited;
210
+ if (exitCode !== 0) {
211
+ const stderr = await new Response(proc.stderr).text();
212
+ throw new Error(stderr.trim() || `git push failed with exit code ${exitCode}`);
213
+ }
214
+ },
215
+ catch: (error) => makeGitError(`es-git.push ${branch}`, error),
216
+ }).pipe(Effect.asVoid);
217
+ }
218
+ return Effect.tryPromise({
182
219
  try: async () => {
183
220
  const remote = repo.getRemote("origin");
184
- const forcePrefix = options?.force === true ? "+" : "";
185
- await remote.push([`${forcePrefix}refs/heads/${branch}:refs/heads/${branch}`]);
221
+ await remote.push([`refs/heads/${branch}:refs/heads/${branch}`]);
186
222
  const localBranch = repo.findBranch(branch, "Local");
187
223
  localBranch?.setUpstream(`origin/${branch}`);
188
224
  },
189
225
  catch: (error) => makeGitError(`es-git.push ${branch}`, error),
190
- }).pipe(Effect.asVoid),
226
+ }).pipe(Effect.asVoid);
227
+ },
191
228
 
192
229
  log: (branch, options) =>
193
230
  Effect.try({
@@ -274,8 +311,25 @@ export const GitEsLayer = Layer.effect(
274
311
  catch: (error) => makeGitError("es-git.isRebaseInProgress", error),
275
312
  }).pipe(Effect.catchTag("GitError", () => Effect.succeed(false))),
276
313
 
277
- commitAmend: () =>
278
- Effect.try({
314
+ commitAmend: (options) => {
315
+ // --edit requires an interactive editor; shell out to git CLI
316
+ if (options?.edit === true) {
317
+ return Effect.tryPromise({
318
+ try: async () => {
319
+ const proc = Bun.spawn(["git", "commit", "--amend"], {
320
+ stdin: "inherit",
321
+ stdout: "inherit",
322
+ stderr: "inherit",
323
+ });
324
+ const exitCode = await proc.exited;
325
+ if (exitCode !== 0) {
326
+ throw new Error(`git commit --amend failed with exit code ${exitCode}`);
327
+ }
328
+ },
329
+ catch: (error) => makeGitError("es-git.commitAmend", error),
330
+ }).pipe(Effect.asVoid);
331
+ }
332
+ return Effect.try({
279
333
  try: () => {
280
334
  const headOid = repo.head().target();
281
335
  if (headOid === null) {
@@ -287,7 +341,8 @@ export const GitEsLayer = Layer.effect(
287
341
  });
288
342
  },
289
343
  catch: (error) => makeGitError("es-git.commitAmend", error),
290
- }).pipe(Effect.asVoid),
344
+ }).pipe(Effect.asVoid);
345
+ },
291
346
 
292
347
  fetch: (remote = "origin") =>
293
348
  Effect.tryPromise({
@@ -252,10 +252,8 @@ const rewriteStackBranches = (
252
252
  };
253
253
  }
254
254
 
255
- const [root] = branches;
256
- if (root === undefined) {
257
- return data;
258
- }
255
+ // branches.length > 0 guaranteed by the early return above
256
+ const root = branches[0] as string;
259
257
 
260
258
  return {
261
259
  ...data,
@@ -636,21 +634,19 @@ export class StackService extends ServiceMap.Service<
636
634
  readonly setTrunk: (name: string) => Effect.Effect<void, StackError>;
637
635
  }
638
636
  >()("@cvr/stacked/services/Stack/StackService") {
639
- static layer: Layer.Layer<StackService, never, GitService> = Layer.effect(
637
+ static layer: Layer.Layer<StackService, StackError, GitService> = Layer.effect(
640
638
  StackService,
641
639
  Effect.gen(function* () {
642
640
  const git = yield* GitService;
643
641
 
644
- const stackFilePath = Effect.fn("StackService.stackFilePath")(function* () {
645
- const gitDir = yield* git
646
- .revParse("--absolute-git-dir")
647
- .pipe(
648
- Effect.mapError(
649
- (e) => new StackError({ message: `Not a git repository: ${e.message}` }),
650
- ),
651
- );
652
- return `${gitDir}/stacked.json`;
653
- });
642
+ // Resolve git dir once at construction time, then capture in closure
643
+ const gitDir = yield* git
644
+ .revParse("--absolute-git-dir")
645
+ .pipe(
646
+ Effect.mapError((e) => new StackError({ message: `Not a git repository: ${e.message}` })),
647
+ );
648
+ const resolvedStackFilePath = `${gitDir}/stacked.json`;
649
+ const stackFilePath = () => Effect.succeed(resolvedStackFilePath);
654
650
 
655
651
  const StackFileJson = Schema.fromJsonString(
656
652
  Schema.Union([StackFileV1Schema, StackFileV2Schema]),