@cvr/stacked 0.4.0 → 0.4.1

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.0",
3
+ "version": "0.4.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/cevr/stacked"
@@ -32,20 +32,20 @@
32
32
  "prepare": "effect-language-service patch && lefthook install"
33
33
  },
34
34
  "dependencies": {
35
- "@effect/platform-bun": "4.0.0-beta.12",
36
- "effect": "4.0.0-beta.12",
35
+ "@effect/platform-bun": "4.0.0-beta.27",
36
+ "effect": "4.0.0-beta.27",
37
37
  "picocolors": "^1.1.1"
38
38
  },
39
39
  "devDependencies": {
40
- "@changesets/changelog-github": "^0.5.2",
41
- "@changesets/cli": "^2.29.8",
42
- "@effect/language-service": "^0.76.0",
43
- "@types/bun": "^1.3.9",
40
+ "@changesets/changelog-github": "^0.6.0",
41
+ "@changesets/cli": "^2.30.0",
42
+ "@effect/language-service": "^0.77.0",
43
+ "@types/bun": "^1.3.10",
44
44
  "concurrently": "^9.2.1",
45
45
  "effect-bun-test": "^0.2.1",
46
- "lefthook": "^2.1.1",
47
- "oxfmt": "^0.35.0",
48
- "oxlint": "^1.50.0",
46
+ "lefthook": "^2.1.2",
47
+ "oxfmt": "^0.36.0",
48
+ "oxlint": "^1.51.0",
49
49
  "typescript": "^5.9.3"
50
50
  }
51
51
  }
@@ -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 stack on trunk (`--from`, `--dry-run`, `--json`) |
48
+ | `stacked sync` | Fetch + rebase + force-push stack on trunk (`--from`, `--dry-run`, `--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`) |
@@ -140,13 +140,13 @@ stacked bottom # jump to bottom (trunk-adjacent)
140
140
 
141
141
  All navigation commands support `--json` for structured output. `checkout` falls through to `git checkout` for branches not in any stack.
142
142
 
143
- ## Syncing / Rebasing
143
+ ## Syncing / Rebasing / Pushing
144
144
 
145
- Fetch latest trunk and rebase the entire stack bottom-to-top:
145
+ Fetch latest trunk, rebase the entire stack bottom-to-top, then force-push each rebased branch (`--force-with-lease`):
146
146
 
147
147
  ```sh
148
148
  stacked sync
149
- stacked sync --dry-run # preview rebase plan without executing
149
+ stacked sync --dry-run # preview rebase/push plan without executing
150
150
  stacked sync --json # structured output: { branches: [{ name, action, base }] }
151
151
  ```
152
152
 
@@ -355,7 +355,7 @@ stacked down # go to previous branch
355
355
  ## Gotchas
356
356
 
357
357
  - `stacked sync` requires a clean working tree — commit or stash first (except `--dry-run`)
358
- - `stacked sync` rebases bottom-to-top resolve conflicts one branch at a time
358
+ - `stacked sync` rebases bottom-to-top and force-pushes each rebased branch with lease
359
359
  - `stacked sync` leaves rebase in progress on conflict — resolve with `git rebase --continue`, then resume with `stacked sync --from <parent>`
360
360
  - `stacked submit` force-pushes by default (use `--no-force` to disable)
361
361
  - `stacked submit` and `stacked clean` require `gh` CLI authenticated (`gh auth login`)
@@ -94,7 +94,8 @@ export const adopt = Command.make("adopt", {
94
94
  yield* Console.log(JSON.stringify({ adopted: branch, stack: stackName }, null, 2));
95
95
  } else {
96
96
  yield* Console.error(`Adopted ${branch} into stack`);
97
- yield* Console.error(dim("Run 'stacked sync' to rebase onto the new parent."));
97
+ const hint = yield* dim("Run 'stacked sync' to rebase onto the new parent.");
98
+ yield* Console.error(hint);
98
99
  }
99
100
  }),
100
101
  ),
@@ -37,14 +37,21 @@ export const amend = Command.make("amend", {
37
37
  });
38
38
  }
39
39
 
40
- yield* git.commitAmend({ edit });
41
-
42
40
  const fromBranch = Option.isSome(from) ? from.value : currentBranch;
43
41
 
44
42
  // Find children to rebase
45
43
  const { branches } = result.stack;
46
44
  const idx = branches.indexOf(fromBranch);
47
- if (idx === -1 || idx >= branches.length - 1) {
45
+ if (idx === -1) {
46
+ return yield* new StackError({
47
+ code: ErrorCode.BRANCH_NOT_FOUND,
48
+ message: `Branch "${fromBranch}" not found in stack "${result.name}"`,
49
+ });
50
+ }
51
+
52
+ yield* git.commitAmend({ edit });
53
+
54
+ if (idx >= branches.length - 1) {
48
55
  if (json) {
49
56
  // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
50
57
  yield* Console.log(JSON.stringify({ amended: currentBranch, synced: [] }, null, 2));
@@ -79,7 +79,8 @@ export const clean = Command.make("clean", { dryRun: dryRunFlag, json: jsonFlag
79
79
  `${skippedMerged.length} merged branch${skippedMerged.length === 1 ? "" : "es"} skipped (non-merged branches below):`,
80
80
  );
81
81
  for (const { branch, stackName } of skippedMerged) {
82
- yield* Console.error(dim(` ${branch} (${stackName})`));
82
+ const line = yield* dim(` ${branch} (${stackName})`);
83
+ yield* Console.error(line);
83
84
  }
84
85
  }
85
86
  }
@@ -88,7 +89,8 @@ export const clean = Command.make("clean", { dryRun: dryRunFlag, json: jsonFlag
88
89
 
89
90
  if (!dryRun) {
90
91
  for (const { branch } of toRemove) {
91
- yield* Console.error(dim(` ${branch}`));
92
+ const line = yield* dim(` ${branch}`);
93
+ yield* Console.error(line);
92
94
  }
93
95
  const confirmed = yield* confirm(
94
96
  `Remove ${toRemove.length} merged branch${toRemove.length === 1 ? "" : "es"}?`,
@@ -146,16 +148,18 @@ export const clean = Command.make("clean", { dryRun: dryRunFlag, json: jsonFlag
146
148
  yield* success(
147
149
  `Cleaned ${removed.length} merged branch${removed.length === 1 ? "" : "es"}`,
148
150
  );
149
- yield* Console.error(
150
- dim("Run 'stacked sync' then 'stacked submit' to rebase and retarget PRs."),
151
+ const hint = yield* dim(
152
+ "Run 'stacked sync' then 'stacked submit' to rebase and retarget PRs.",
151
153
  );
154
+ yield* Console.error(hint);
152
155
 
153
156
  if (skippedMerged.length > 0) {
154
157
  yield* warn(
155
158
  `${skippedMerged.length} merged branch${skippedMerged.length === 1 ? "" : "es"} skipped (non-merged branches below):`,
156
159
  );
157
160
  for (const { branch, stackName } of skippedMerged) {
158
- yield* Console.error(dim(` ${branch} (${stackName})`));
161
+ const line = yield* dim(` ${branch} (${stackName})`);
162
+ yield* Console.error(line);
159
163
  }
160
164
  }
161
165
  }
@@ -3,6 +3,7 @@ import { Console, Effect } from "effect";
3
3
  import { GitService } from "../services/Git.js";
4
4
  import { StackService } from "../services/Stack.js";
5
5
  import { success, warn, info } from "../ui.js";
6
+ import { detectLimitConfig, limitUntrackedBranches } from "./helpers/detect.js";
6
7
 
7
8
  const dryRunFlag = Flag.boolean("dry-run").pipe(
8
9
  Flag.withDescription("Show what would be detected without making changes"),
@@ -26,13 +27,21 @@ export const detect = Command.make("detect", { dryRun: dryRunFlag, json: jsonFla
26
27
 
27
28
  const data = yield* stacks.load();
28
29
  const alreadyTracked = new Set(Object.values(data.stacks).flatMap((s) => [...s.branches]));
29
- const untracked = candidates.filter((b) => !alreadyTracked.has(b));
30
+ const untrackedAll = candidates.filter((b) => !alreadyTracked.has(b));
31
+ const detectLimit = yield* detectLimitConfig;
32
+ const { untracked, skipped } = limitUntrackedBranches(untrackedAll, detectLimit);
30
33
 
31
34
  if (untracked.length === 0) {
32
35
  yield* Console.error("No untracked branches found");
33
36
  return;
34
37
  }
35
38
 
39
+ if (skipped > 0) {
40
+ yield* warn(
41
+ `Analyzing ${untracked.length}/${untrackedAll.length} untracked branches (set STACKED_DETECT_MAX_BRANCHES to adjust)`,
42
+ );
43
+ }
44
+
36
45
  // Build parent map: for each branch, find its direct parent among other branches
37
46
  // A parent is the closest ancestor — i.e., an ancestor that is not an ancestor of another ancestor
38
47
  const childOf = new Map<string, string>();
@@ -75,15 +84,24 @@ export const detect = Command.make("detect", { dryRun: dryRunFlag, json: jsonFla
75
84
 
76
85
  // Build linear chains from trunk
77
86
  // Find branches whose parent is trunk (chain roots)
87
+ const childrenByParent = new Map<string, string[]>();
88
+ for (const branch of untracked) {
89
+ const parent = childOf.get(branch);
90
+ if (parent === undefined) continue;
91
+ const children = childrenByParent.get(parent) ?? [];
92
+ children.push(branch);
93
+ childrenByParent.set(parent, children);
94
+ }
95
+
78
96
  const chains: string[][] = [];
79
- const roots = untracked.filter((b) => childOf.get(b) === trunk);
97
+ const roots = childrenByParent.get(trunk) ?? [];
80
98
 
81
99
  for (const root of roots) {
82
100
  const chain = [root];
83
101
  let current = root;
84
102
 
85
103
  while (true) {
86
- const children = untracked.filter((b) => childOf.get(b) === current);
104
+ const children = childrenByParent.get(current) ?? [];
87
105
  const child = children[0];
88
106
  if (children.length === 1 && child !== undefined) {
89
107
  chain.push(child);
@@ -98,13 +116,10 @@ export const detect = Command.make("detect", { dryRun: dryRunFlag, json: jsonFla
98
116
  }
99
117
 
100
118
  // Report forks
101
- const forkPoints = untracked.filter((b) => {
102
- const children = untracked.filter((c) => childOf.get(c) === b);
103
- return children.length > 1;
104
- });
119
+ const forkPoints = untracked.filter((b) => (childrenByParent.get(b)?.length ?? 0) > 1);
105
120
  const forks = forkPoints.map((b) => ({
106
121
  branch: b,
107
- children: untracked.filter((c) => childOf.get(c) === b),
122
+ children: childrenByParent.get(b) ?? [],
108
123
  }));
109
124
 
110
125
  if (json) {
@@ -0,0 +1,22 @@
1
+ import { Config } from "effect";
2
+
3
+ export const DEFAULT_MAX_DETECT_BRANCHES = 200;
4
+
5
+ export interface LimitedBranches {
6
+ readonly untracked: readonly string[];
7
+ readonly skipped: number;
8
+ }
9
+
10
+ export const detectLimitConfig = Config.int("STACKED_DETECT_MAX_BRANCHES").pipe(
11
+ Config.withDefault(DEFAULT_MAX_DETECT_BRANCHES),
12
+ Config.orElse(() => Config.succeed(DEFAULT_MAX_DETECT_BRANCHES)),
13
+ Config.map((value) => (value > 0 ? value : DEFAULT_MAX_DETECT_BRANCHES)),
14
+ );
15
+
16
+ export const limitUntrackedBranches = (
17
+ untrackedAll: readonly string[],
18
+ limit: number,
19
+ ): LimitedBranches => {
20
+ const untracked = untrackedAll.slice(0, limit);
21
+ return { untracked, skipped: untrackedAll.length - untracked.length };
22
+ };
@@ -1,5 +1,5 @@
1
1
  import { Command } from "effect/unstable/cli";
2
- import { Console, Effect } from "effect";
2
+ import { Config, Console, Effect } from "effect";
3
3
  import { StackError } from "../errors/index.js";
4
4
  import { mkdirSync, writeFileSync } from "fs";
5
5
  import { join } from "path";
@@ -18,7 +18,9 @@ export const init = Command.make("init").pipe(
18
18
  });
19
19
  }
20
20
 
21
- const skillsDir = process.env["STACKED_SKILLS_DIR"] ?? join(homedir(), ".claude", "skills");
21
+ const skillsDir = yield* Config.string("STACKED_SKILLS_DIR").pipe(
22
+ Config.withDefault(join(homedir(), ".claude", "skills")),
23
+ );
22
24
  const targetDir = join(skillsDir, "stacked");
23
25
  const targetPath = join(targetDir, "SKILL.md");
24
26
 
@@ -84,27 +84,27 @@ export const list = Command.make("list", { stackName: stackNameArg, json: jsonFl
84
84
 
85
85
  const lines: string[] = [];
86
86
 
87
- lines.push(`Stack: ${stdout.bold(targetStackName)}`);
88
- lines.push(`Trunk: ${stdout.dim(trunk)}`);
87
+ lines.push(`Stack: ${yield* stdout.bold(targetStackName)}`);
88
+ lines.push(`Trunk: ${yield* stdout.dim(trunk)}`);
89
89
  lines.push("");
90
90
 
91
91
  for (let i = targetStack.branches.length - 1; i >= 0; i--) {
92
92
  const branch = targetStack.branches[i];
93
93
  if (branch === undefined) continue;
94
94
  const isCurrent = branch === currentBranch;
95
- const marker = isCurrent ? stdout.green("* ") : " ";
96
- const prefix = stdout.dim(i === 0 ? "└─" : "├─");
97
- const name = isCurrent ? stdout.bold(branch) : branch;
95
+ const marker = isCurrent ? yield* stdout.green("* ") : " ";
96
+ const prefix = yield* stdout.dim(i === 0 ? "└─" : "├─");
97
+ const name = isCurrent ? yield* stdout.bold(branch) : branch;
98
98
 
99
99
  const pr = prMap.get(branch) ?? null;
100
100
  const status =
101
101
  pr === null
102
102
  ? ""
103
103
  : pr.state === "MERGED"
104
- ? stdout.green(" [merged]")
104
+ ? yield* stdout.green(" [merged]")
105
105
  : pr.state === "CLOSED"
106
- ? stdout.dim(" [closed]")
107
- : stdout.cyan(` [#${pr.number}]`);
106
+ ? yield* stdout.dim(" [closed]")
107
+ : yield* stdout.cyan(` [#${pr.number}]`);
108
108
 
109
109
  lines.push(`${marker}${prefix} ${name}${status}`);
110
110
  }
@@ -56,6 +56,16 @@ export const reorder = Command.make("reorder", {
56
56
  if (currentIdx === -1) return;
57
57
 
58
58
  const target = Option.isSome(before) ? before.value : Option.getOrElse(after, () => "");
59
+ if (target === branch) {
60
+ if (json) {
61
+ // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
62
+ yield* Console.log(JSON.stringify({ branch, stack: stackName, branches }, null, 2));
63
+ } else {
64
+ yield* warn(`"${branch}" is already at the requested position`);
65
+ }
66
+ return;
67
+ }
68
+
59
69
  const targetIdx = branches.indexOf(target);
60
70
  if (targetIdx === -1) {
61
71
  return yield* new StackError({
@@ -45,12 +45,11 @@ export const stacks = Command.make("stacks", { json: jsonFlag }).pipe(
45
45
  const lines: string[] = [];
46
46
  for (const [name, stack] of entries) {
47
47
  const isCurrent = stack.branches.includes(currentBranch);
48
- const marker = isCurrent ? stdout.green("* ") : " ";
49
- const label = isCurrent ? stdout.bold(name) : name;
48
+ const marker = isCurrent ? yield* stdout.green("* ") : " ";
49
+ const label = isCurrent ? yield* stdout.bold(name) : name;
50
50
  const count = stack.branches.length;
51
- lines.push(
52
- `${marker}${label} ${stdout.dim(`(${count} branch${count === 1 ? "" : "es"})`)}`,
53
- );
51
+ const countText = yield* stdout.dim(`(${count} branch${count === 1 ? "" : "es"})`);
52
+ lines.push(`${marker}${label} ${countText}`);
54
53
  }
55
54
 
56
55
  yield* Console.log(lines.join("\n"));
@@ -36,17 +36,19 @@ export const status = Command.make("status", { json: jsonFlag }).pipe(
36
36
  }
37
37
 
38
38
  const lines: string[] = [];
39
- lines.push(`Branch: ${stdout.bold(currentBranch)}`);
40
- lines.push(`Working tree: ${clean ? stdout.green("clean") : stdout.yellow("dirty")}`);
39
+ lines.push(`Branch: ${yield* stdout.bold(currentBranch)}`);
40
+ lines.push(
41
+ `Working tree: ${clean ? yield* stdout.green("clean") : yield* stdout.yellow("dirty")}`,
42
+ );
41
43
 
42
44
  if (result !== null) {
43
45
  const { branches } = result.stack;
44
46
  const idx = branches.indexOf(currentBranch);
45
- lines.push(
46
- `Stack: ${stdout.bold(result.name)} ${stdout.dim(`(${idx + 1} of ${branches.length})`)}`,
47
- );
47
+ const stackName = yield* stdout.bold(result.name);
48
+ const position = yield* stdout.dim(`(${idx + 1} of ${branches.length})`);
49
+ lines.push(`Stack: ${stackName} ${position}`);
48
50
  } else {
49
- lines.push(stdout.dim("Not in a stack. Run 'stacked create <name>' to start one."));
51
+ lines.push(yield* stdout.dim("Not in a stack. Run 'stacked create <name>' to start one."));
50
52
  }
51
53
 
52
54
  yield* Console.log(lines.join("\n"));
@@ -32,9 +32,10 @@ const onlyFlag = Flag.boolean("only").pipe(Flag.withDescription("Only submit the
32
32
 
33
33
  interface SubmitResult {
34
34
  branch: string;
35
- number: number;
36
- url: string;
37
- action: "created" | "updated" | "unchanged";
35
+ base: string;
36
+ number?: number;
37
+ url?: string;
38
+ action: "created" | "updated" | "unchanged" | "would-create" | "would-update" | "would-unchanged";
38
39
  }
39
40
 
40
41
  const STACKED_MARKER_START = "<!-- stacked -->";
@@ -210,7 +211,23 @@ export const submit = Command.make("submit", {
210
211
  if (only && branch !== currentBranch) continue;
211
212
 
212
213
  if (dryRun) {
213
- yield* Console.error(`Would push ${branch} and create/update PR (base: ${base})`);
214
+ const existingPR = yield* gh.getPR(branch);
215
+ const action =
216
+ existingPR === null
217
+ ? "would-create"
218
+ : existingPR.base !== base
219
+ ? "would-update"
220
+ : "would-unchanged";
221
+ results.push({
222
+ branch,
223
+ base,
224
+ number: existingPR?.number,
225
+ url: existingPR?.url,
226
+ action,
227
+ });
228
+ if (!json) {
229
+ yield* Console.error(`Would push ${branch} and create/update PR (base: ${base})`);
230
+ }
214
231
  continue;
215
232
  }
216
233
 
@@ -225,6 +242,7 @@ export const submit = Command.make("submit", {
225
242
  yield* gh.updatePR({ branch, base });
226
243
  results.push({
227
244
  branch,
245
+ base,
228
246
  number: existingPR.number,
229
247
  url: existingPR.url,
230
248
  action: "updated",
@@ -233,6 +251,7 @@ export const submit = Command.make("submit", {
233
251
  yield* Console.error(`PR #${existingPR.number} already exists: ${existingPR.url}`);
234
252
  results.push({
235
253
  branch,
254
+ base,
236
255
  number: existingPR.number,
237
256
  url: existingPR.url,
238
257
  action: "unchanged",
@@ -257,6 +276,7 @@ export const submit = Command.make("submit", {
257
276
  yield* success(`Created PR #${pr.number}: ${pr.url}`);
258
277
  results.push({
259
278
  branch,
279
+ base,
260
280
  number: pr.number,
261
281
  url: pr.url,
262
282
  action: "created",
@@ -264,16 +284,23 @@ export const submit = Command.make("submit", {
264
284
  }
265
285
  }
266
286
 
267
- if (dryRun) return;
287
+ if (dryRun) {
288
+ if (json) {
289
+ // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
290
+ yield* Console.log(JSON.stringify({ results }, null, 2));
291
+ }
292
+ return;
293
+ }
268
294
 
269
- // Update existing PRs with stack metadata
295
+ // Update all processed PRs with complete stack metadata.
296
+ // This includes newly created PRs so placeholders get replaced in one submit run.
270
297
  for (let i = 0; i < branches.length; i++) {
271
298
  const branch = branches[i];
272
299
  if (branch === undefined) continue;
273
300
  if (only && branch !== currentBranch) continue;
274
301
 
275
302
  const entry = results.find((x) => x.branch === branch);
276
- if (entry === undefined || entry.action === "created") continue;
303
+ if (entry === undefined) continue;
277
304
 
278
305
  const metadata = generateStackMetadata(branches, prMap, i, result.name);
279
306
  const existingPrData = prMap.get(branch) ?? null;
@@ -32,7 +32,9 @@ export const sync = Command.make("sync", {
32
32
  json: jsonFlag,
33
33
  dryRun: dryRunFlag,
34
34
  }).pipe(
35
- Command.withDescription("Fetch and rebase stack on trunk. Use --from to start from a branch."),
35
+ Command.withDescription(
36
+ "Fetch, rebase, and force-push stack branches. Use --from to start from a branch.",
37
+ ),
36
38
  Command.withExamples([
37
39
  { command: "stacked sync", description: "Rebase entire stack on trunk" },
38
40
  { command: "stacked sync --from feat-auth", description: "Resume from a specific branch" },
@@ -93,7 +95,7 @@ export const sync = Command.make("sync", {
93
95
  const base = i === 0 ? `origin/${trunk}` : (branches[i - 1] ?? `origin/${trunk}`);
94
96
  results.push({ name: branch, action: "skipped", base });
95
97
  if (!json) {
96
- yield* Console.error(`Would rebase ${branch} onto ${base}`);
98
+ yield* Console.error(`Would rebase and force-push ${branch} onto ${base}`);
97
99
  }
98
100
  }
99
101
 
@@ -137,6 +139,7 @@ export const sync = Command.make("sync", {
137
139
  );
138
140
  }),
139
141
  );
142
+ yield* withSpinner(`Pushing ${branch}`, git.push(branch, { force: true }));
140
143
  results.push({ name: branch, action: "rebased", base: newBase });
141
144
  }
142
145
  }).pipe(
@@ -1,4 +1,4 @@
1
- import { Schema } from "effect";
1
+ import { Runtime, Schema } from "effect";
2
2
 
3
3
  // ============================================================================
4
4
  // Error Codes
@@ -43,3 +43,9 @@ export class GitHubError extends Schema.TaggedErrorClass<GitHubError>()("GitHubE
43
43
  command: Schema.optional(Schema.String),
44
44
  code: Schema.optional(Schema.String),
45
45
  }) {}
46
+
47
+ export class GlobalFlagConflictError extends Error {
48
+ override readonly name = "GlobalFlagConflictError";
49
+ override readonly [Runtime.errorExitCode] = 2;
50
+ override readonly [Runtime.errorReported] = false;
51
+ }
package/src/main.ts CHANGED
@@ -7,6 +7,7 @@ import { GitService } from "./services/Git.js";
7
7
  import { StackService } from "./services/Stack.js";
8
8
  import { GitHubService } from "./services/GitHub.js";
9
9
  import { OutputConfig } from "./ui.js";
10
+ import { GlobalFlagConflictError } from "./errors/index.js";
10
11
 
11
12
  const version = typeof __VERSION__ !== "undefined" ? __VERSION__ : "dev";
12
13
 
@@ -25,10 +26,12 @@ const isYes = flagArgs.has("--yes") || flagArgs.has("-y");
25
26
 
26
27
  if (isNoColor) process.env["NO_COLOR"] = "1";
27
28
 
28
- if (isVerbose && isQuiet) {
29
- process.stderr.write("Error: --verbose and --quiet are mutually exclusive\n");
30
- process.exitCode = 2;
31
- }
29
+ const preflight =
30
+ isVerbose && isQuiet
31
+ ? Console.error("Error: --verbose and --quiet are mutually exclusive").pipe(
32
+ Effect.andThen(Effect.fail(new GlobalFlagConflictError())),
33
+ )
34
+ : Effect.void;
32
35
 
33
36
  // ============================================================================
34
37
  // CLI
@@ -70,7 +73,8 @@ const handleKnownError = (e: { message: string; code?: string | undefined }) =>
70
73
 
71
74
  // @effect-diagnostics-next-line effect/strictEffectProvide:off
72
75
  BunRuntime.runMain(
73
- cli.pipe(
76
+ preflight.pipe(
77
+ Effect.andThen(cli),
74
78
  Effect.provideService(OutputConfig, { verbose: isVerbose, quiet: isQuiet, yes: isYes }),
75
79
  Effect.provide(AppLayer),
76
80
  Effect.catchTags({
@@ -78,12 +82,16 @@ BunRuntime.runMain(
78
82
  StackError: (e) => handleKnownError(e),
79
83
  GitHubError: (e) => handleKnownError(e),
80
84
  }),
81
- Effect.catch((e) => {
82
- const msg =
83
- e !== null && typeof e === "object" && "message" in e
84
- ? String(e.message)
85
- : JSON.stringify(e, null, 2);
86
- return handleKnownError({ message: `Unexpected error: ${msg}` });
87
- }),
85
+ Effect.catchIf(
86
+ (e): e is GlobalFlagConflictError => e instanceof GlobalFlagConflictError,
87
+ Effect.fail,
88
+ (e) => {
89
+ const msg =
90
+ e !== null && typeof e === "object" && "message" in e
91
+ ? String(e.message)
92
+ : JSON.stringify(e, null, 2);
93
+ return handleKnownError({ message: `Unexpected error: ${msg}` });
94
+ },
95
+ ),
88
96
  ),
89
97
  );
@@ -93,7 +93,12 @@ export class GitService extends ServiceMap.Service<
93
93
  ),
94
94
 
95
95
  listBranches: () =>
96
- run(["branch", "--format=%(refname:short)"]).pipe(
96
+ run([
97
+ "for-each-ref",
98
+ "--sort=-committerdate",
99
+ "--format=%(refname:short)",
100
+ "refs/heads",
101
+ ]).pipe(
97
102
  Effect.map((output) =>
98
103
  output
99
104
  .split("\n")
package/src/ui.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createInterface } from "node:readline";
2
2
  import pc from "picocolors";
3
- import { Effect, ServiceMap } from "effect";
3
+ import { Config, Effect, ServiceMap } from "effect";
4
4
 
5
5
  // ============================================================================
6
6
  // TTY & Color Detection
@@ -13,24 +13,43 @@ const stdoutIsTTY = process.stdout.isTTY === true;
13
13
  let _stderrColors: ReturnType<typeof pc.createColors> | null = null;
14
14
  let _stdoutColors: ReturnType<typeof pc.createColors> | null = null;
15
15
 
16
- const isColorEnabled = (isTTY: boolean) => {
17
- if (process.env["NO_COLOR"] !== undefined) return false;
18
- if (process.env["FORCE_COLOR"] !== undefined) return true;
19
- if (process.env["TERM"] === "dumb") return false;
16
+ const colorRuntimeConfig = Config.all({
17
+ noColor: Config.string("NO_COLOR").pipe(
18
+ Config.map(() => true),
19
+ Config.orElse(() => Config.succeed(false)),
20
+ ),
21
+ forceColor: Config.string("FORCE_COLOR").pipe(
22
+ Config.map(() => true),
23
+ Config.orElse(() => Config.succeed(false)),
24
+ ),
25
+ term: Config.string("TERM").pipe(Config.withDefault("")),
26
+ });
27
+
28
+ const isColorEnabled = Effect.fn("ui.isColorEnabled")(function* (isTTY: boolean) {
29
+ const runtime = yield* colorRuntimeConfig;
30
+ if (runtime.noColor) return false;
31
+ if (runtime.forceColor) return true;
32
+ if (runtime.term === "dumb") return false;
20
33
  return isTTY;
21
- };
34
+ });
22
35
 
23
- const getColors = () => {
36
+ const getColors = Effect.fn("ui.getColors")(function* () {
24
37
  if (_stderrColors !== null) return _stderrColors;
25
- _stderrColors = isColorEnabled(stderrIsTTY) ? pc : pc.createColors(false);
38
+ const enabled = yield* isColorEnabled(stderrIsTTY).pipe(
39
+ Effect.catchTag("ConfigError", () => Effect.succeed(false)),
40
+ );
41
+ _stderrColors = enabled ? pc : pc.createColors(false);
26
42
  return _stderrColors;
27
- };
43
+ });
28
44
 
29
- const getStdoutColors = () => {
45
+ const getStdoutColors = Effect.fn("ui.getStdoutColors")(function* () {
30
46
  if (_stdoutColors !== null) return _stdoutColors;
31
- _stdoutColors = isColorEnabled(stdoutIsTTY) ? pc : pc.createColors(false);
47
+ const enabled = yield* isColorEnabled(stdoutIsTTY).pipe(
48
+ Effect.catchTag("ConfigError", () => Effect.succeed(false)),
49
+ );
50
+ _stdoutColors = enabled ? pc : pc.createColors(false);
32
51
  return _stdoutColors;
33
- };
52
+ });
34
53
 
35
54
  // ============================================================================
36
55
  // Output Config (verbose/quiet, set by global flags)
@@ -84,27 +103,34 @@ const write = (msg: string) =>
84
103
  export const success = Effect.fn("ui.success")(function* (msg: string) {
85
104
  const config = yield* OutputConfig;
86
105
  if (config.quiet) return;
87
- yield* write(getColors().green(`✓ ${msg}`));
106
+ const colors = yield* getColors();
107
+ yield* write(colors.green(`✓ ${msg}`));
88
108
  });
89
109
 
90
110
  export const warn = Effect.fn("ui.warn")(function* (msg: string) {
91
111
  const config = yield* OutputConfig;
92
112
  if (config.quiet) return;
93
- yield* write(getColors().yellow(`⚠ ${msg}`));
113
+ const colors = yield* getColors();
114
+ yield* write(colors.yellow(`⚠ ${msg}`));
94
115
  });
95
116
 
96
117
  export const info = Effect.fn("ui.info")(function* (msg: string) {
97
118
  const config = yield* OutputConfig;
98
119
  if (config.quiet) return;
99
- yield* write(getColors().cyan(msg));
120
+ const colors = yield* getColors();
121
+ yield* write(colors.cyan(msg));
100
122
  });
101
123
 
102
- export const error = (msg: string) => write(getColors().red(msg));
124
+ export const error = Effect.fn("ui.error")(function* (msg: string) {
125
+ const colors = yield* getColors();
126
+ yield* write(colors.red(msg));
127
+ });
103
128
 
104
129
  export const verbose = Effect.fn("ui.verbose")(function* (msg: string) {
105
130
  const config = yield* OutputConfig;
106
131
  if (!config.verbose) return;
107
- yield* write(getColors().dim(msg));
132
+ const colors = yield* getColors();
133
+ yield* write(colors.dim(msg));
108
134
  });
109
135
 
110
136
  // ============================================================================
@@ -122,7 +148,7 @@ export const withSpinner = <A, E, R>(
122
148
  }
123
149
 
124
150
  return Effect.gen(function* () {
125
- const c = getColors();
151
+ const c = yield* getColors();
126
152
  let frame = 0;
127
153
  const interval = setInterval(() => {
128
154
  const spinner = SPINNER_FRAMES[frame % SPINNER_FRAMES.length];
@@ -150,24 +176,72 @@ export const withSpinner = <A, E, R>(
150
176
  // Color Helpers — stderr (for tree views, status badges, etc.)
151
177
  // ============================================================================
152
178
 
153
- export const dim = (s: string) => getColors().dim(s);
154
- export const bold = (s: string) => getColors().bold(s);
155
- export const green = (s: string) => getColors().green(s);
156
- export const yellow = (s: string) => getColors().yellow(s);
157
- export const cyan = (s: string) => getColors().cyan(s);
158
- export const red = (s: string) => getColors().red(s);
159
- export const magenta = (s: string) => getColors().magenta(s);
179
+ export const dim = Effect.fn("ui.dim")(function* (s: string) {
180
+ const colors = yield* getColors();
181
+ return colors.dim(s);
182
+ });
183
+
184
+ export const bold = Effect.fn("ui.bold")(function* (s: string) {
185
+ const colors = yield* getColors();
186
+ return colors.bold(s);
187
+ });
188
+
189
+ export const green = Effect.fn("ui.green")(function* (s: string) {
190
+ const colors = yield* getColors();
191
+ return colors.green(s);
192
+ });
193
+
194
+ export const yellow = Effect.fn("ui.yellow")(function* (s: string) {
195
+ const colors = yield* getColors();
196
+ return colors.yellow(s);
197
+ });
198
+
199
+ export const cyan = Effect.fn("ui.cyan")(function* (s: string) {
200
+ const colors = yield* getColors();
201
+ return colors.cyan(s);
202
+ });
203
+
204
+ export const red = Effect.fn("ui.red")(function* (s: string) {
205
+ const colors = yield* getColors();
206
+ return colors.red(s);
207
+ });
208
+
209
+ export const magenta = Effect.fn("ui.magenta")(function* (s: string) {
210
+ const colors = yield* getColors();
211
+ return colors.magenta(s);
212
+ });
160
213
 
161
214
  // ============================================================================
162
215
  // Color Helpers — stdout (for Console.log output that may be piped)
163
216
  // ============================================================================
164
217
 
165
218
  export const stdout = {
166
- dim: (s: string) => getStdoutColors().dim(s),
167
- bold: (s: string) => getStdoutColors().bold(s),
168
- green: (s: string) => getStdoutColors().green(s),
169
- yellow: (s: string) => getStdoutColors().yellow(s),
170
- cyan: (s: string) => getStdoutColors().cyan(s),
171
- red: (s: string) => getStdoutColors().red(s),
172
- magenta: (s: string) => getStdoutColors().magenta(s),
219
+ dim: Effect.fn("ui.stdout.dim")(function* (s: string) {
220
+ const colors = yield* getStdoutColors();
221
+ return colors.dim(s);
222
+ }),
223
+ bold: Effect.fn("ui.stdout.bold")(function* (s: string) {
224
+ const colors = yield* getStdoutColors();
225
+ return colors.bold(s);
226
+ }),
227
+ green: Effect.fn("ui.stdout.green")(function* (s: string) {
228
+ const colors = yield* getStdoutColors();
229
+ return colors.green(s);
230
+ }),
231
+ yellow: Effect.fn("ui.stdout.yellow")(function* (s: string) {
232
+ const colors = yield* getStdoutColors();
233
+ return colors.yellow(s);
234
+ }),
235
+ cyan: Effect.fn("ui.stdout.cyan")(function* (s: string) {
236
+ const colors = yield* getStdoutColors();
237
+ return colors.cyan(s);
238
+ }),
239
+ red: Effect.fn("ui.stdout.red")(function* (s: string) {
240
+ const colors = yield* getStdoutColors();
241
+ return colors.red(s);
242
+ }),
243
+ magenta: Effect.fn("ui.stdout.magenta")(function* (s: string) {
244
+ const colors = yield* getStdoutColors();
245
+ return colors.magenta(s);
246
+ }),
173
247
  };