@cvr/stacked 0.4.1 → 0.4.3

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/README.md CHANGED
@@ -13,11 +13,12 @@ bun run build # compiles binary to bin/stacked + symlinks to ~/.bun/bin/
13
13
  ## Setup
14
14
 
15
15
  ```sh
16
- # Trunk is auto-detected (main > master > develop). Override if needed:
17
- stacked trunk develop
18
-
19
16
  # Install the Claude skill (optional):
20
17
  stacked init
18
+
19
+ # Trunk is auto-detected on first use from origin/HEAD when available
20
+ # (fallback: main > master > develop). Override only if needed:
21
+ stacked trunk <name>
21
22
  ```
22
23
 
23
24
  ## Usage
@@ -131,7 +132,7 @@ stacked --yes clean # skip confirmation prompts
131
132
 
132
133
  Stack metadata lives in `.git/stacked.json`. Each branch's parent is implied by array position — `branches[0]`'s parent is trunk, `branches[n]`'s parent is `branches[n-1]`.
133
134
 
134
- Trunk is auto-detected on first use by checking for `main`, `master`, or `develop` branches. Override with `stacked trunk <name>`.
135
+ 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>`.
135
136
 
136
137
  ## Output Conventions
137
138
 
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.1",
3
+ "version": "0.4.3",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/cevr/stacked"
@@ -71,11 +71,12 @@ What do you need?
71
71
  ## Setup
72
72
 
73
73
  ```sh
74
- # Trunk is auto-detected (main > master > develop). Override if needed:
75
- stacked trunk develop
76
-
77
74
  # Install the Claude skill (optional, compiled binary only):
78
75
  stacked init
76
+
77
+ # Trunk is auto-detected on first use from origin/HEAD when available
78
+ # (fallback: main > master > develop). Override only if needed:
79
+ stacked trunk <name>
79
80
  ```
80
81
 
81
82
  Requires `gh` CLI installed and authenticated for `submit` and `clean`.
@@ -315,7 +316,7 @@ Stack metadata lives in `.git/stacked.json`. Each branch's parent is implied by
315
316
  - `branches[0]` → parent is trunk
316
317
  - `branches[n]` → parent is `branches[n-1]`
317
318
 
318
- Trunk is auto-detected on first use by checking for `main`, `master`, or `develop` branches. Override with `stacked trunk <name>`.
319
+ 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>`.
319
320
 
320
321
  A repo can have multiple independent stacks. The current stack is determined by which branch you're on.
321
322
 
@@ -361,7 +362,7 @@ stacked down # go to previous branch
361
362
  - `stacked submit` and `stacked clean` require `gh` CLI authenticated (`gh auth login`)
362
363
  - PRs target parent branches, not trunk — this is intentional for stacked review
363
364
  - PRs include auto-generated stack metadata (position, navigation links)
364
- - Trunk is auto-detected (`main` > `master` > `develop`) — use `stacked trunk <name>` to override
365
+ - Trunk is auto-detected (`origin/HEAD` first, then `main` > `master` > `develop`) — use `stacked trunk <name>` only when detection is wrong
365
366
  - Forked branches (one parent, multiple children) are not supported — `detect` reports them but skips
366
367
  - `stacked delete --force` on a mid-stack branch requires `stacked sync` afterward
367
368
  - `stacked checkout` falls through to `git checkout` for branches not in a stack
@@ -1,5 +1,5 @@
1
1
  import { Command, Flag } from "effect/unstable/cli";
2
- import { Console, Effect } from "effect";
2
+ import { Console, Effect, Option } from "effect";
3
3
  import { GitService } from "../services/Git.js";
4
4
  import { StackService } from "../services/Stack.js";
5
5
  import { success, warn } from "../ui.js";
@@ -33,20 +33,20 @@ export const doctor = Command.make("doctor", { fix: fixFlag, json: jsonFlag }).p
33
33
  .pipe(Effect.catchTag("GitError", () => Effect.succeed(false)));
34
34
  if (!trunkExists) {
35
35
  if (fix) {
36
- // Auto-detect a trunk
37
- for (const candidate of ["main", "master", "develop"]) {
38
- const exists = yield* git
39
- .branchExists(candidate)
40
- .pipe(Effect.catchTag("GitError", () => Effect.succeed(false)));
41
- if (exists) {
42
- yield* stacks.setTrunk(candidate);
43
- findings.push({
44
- type: "missing_trunk",
45
- message: `Trunk "${data.trunk}" not found, set to "${candidate}"`,
46
- fixed: true,
47
- });
48
- break;
49
- }
36
+ const candidate = yield* stacks.detectTrunkCandidate();
37
+ if (Option.isSome(candidate)) {
38
+ yield* stacks.setTrunk(candidate.value);
39
+ findings.push({
40
+ type: "missing_trunk",
41
+ message: `Trunk "${data.trunk}" not found, set to "${candidate.value}"`,
42
+ fixed: true,
43
+ });
44
+ } else {
45
+ findings.push({
46
+ type: "missing_trunk",
47
+ message: `Trunk branch "${data.trunk}" does not exist and no replacement could be auto-detected`,
48
+ fixed: false,
49
+ });
50
50
  }
51
51
  } else {
52
52
  findings.push({
@@ -30,7 +30,7 @@ const root = Command.make("stacked").pipe(
30
30
  Command.withExamples([
31
31
  { command: "stacked create feat-auth", description: "Create a new branch in the stack" },
32
32
  { command: "stacked list", description: "Show branches in the current stack" },
33
- { command: "stacked sync", description: "Rebase all branches in order" },
33
+ { command: "stacked sync", description: "Update trunk, then rebase stack branches in order" },
34
34
  { command: "stacked submit", description: "Push and create/update PRs" },
35
35
  ]),
36
36
  );
@@ -35,8 +35,10 @@ export const init = Command.make("init").pipe(
35
35
 
36
36
  yield* Console.error(`Installed stacked skill to ${targetPath}`);
37
37
  yield* Console.error("\nNext steps:");
38
- yield* Console.error(" stacked trunk # verify/set your trunk branch");
39
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
42
  }),
41
43
  ),
42
44
  );
@@ -36,7 +36,7 @@ export const sync = Command.make("sync", {
36
36
  "Fetch, rebase, and force-push stack branches. Use --from to start from a branch.",
37
37
  ),
38
38
  Command.withExamples([
39
- { command: "stacked sync", description: "Rebase entire stack on trunk" },
39
+ { command: "stacked sync", description: "Sync local trunk, then rebase entire stack on trunk" },
40
40
  { command: "stacked sync --from feat-auth", description: "Resume from a specific branch" },
41
41
  { command: "stacked sync --dry-run", description: "Preview rebase plan" },
42
42
  ]),
@@ -46,6 +46,7 @@ export const sync = Command.make("sync", {
46
46
  const stacks = yield* StackService;
47
47
 
48
48
  const trunk = Option.isSome(trunkOpt) ? trunkOpt.value : yield* stacks.getTrunk();
49
+ const originTrunk = `origin/${trunk}`;
49
50
  const currentBranch = yield* git.currentBranch();
50
51
 
51
52
  if (!dryRun) {
@@ -89,10 +90,15 @@ export const sync = Command.make("sync", {
89
90
  const results: SyncResult[] = [];
90
91
 
91
92
  if (dryRun) {
93
+ results.push({ name: trunk, action: "skipped", base: originTrunk });
94
+ if (!json) {
95
+ yield* Console.error(`Would fetch and rebase ${trunk} onto ${originTrunk}`);
96
+ }
97
+
92
98
  for (let i = startIdx; i < branches.length; i++) {
93
99
  const branch = branches[i];
94
100
  if (branch === undefined) continue;
95
- const base = i === 0 ? `origin/${trunk}` : (branches[i - 1] ?? `origin/${trunk}`);
101
+ const base = i === 0 ? originTrunk : (branches[i - 1] ?? originTrunk);
96
102
  results.push({ name: branch, action: "skipped", base });
97
103
  if (!json) {
98
104
  yield* Console.error(`Would rebase and force-push ${branch} onto ${base}`);
@@ -110,13 +116,25 @@ export const sync = Command.make("sync", {
110
116
  return;
111
117
  }
112
118
 
113
- yield* withSpinner(`Fetching ${trunk}`, git.fetch());
114
-
115
119
  yield* Effect.gen(function* () {
120
+ yield* withSpinner(`Fetching ${trunk}`, git.fetch());
121
+ yield* git.checkout(trunk);
122
+ yield* withSpinner(`Rebasing ${trunk} onto ${originTrunk}`, git.rebase(originTrunk)).pipe(
123
+ Effect.catchTag("GitError", (e) =>
124
+ Effect.fail(
125
+ new StackError({
126
+ code: ErrorCode.REBASE_CONFLICT,
127
+ message: `Rebase conflict on ${trunk}: ${e.message}\n\nResolve conflicts, then run:\n git rebase --continue`,
128
+ }),
129
+ ),
130
+ ),
131
+ );
132
+ results.push({ name: trunk, action: "rebased", base: originTrunk });
133
+
116
134
  for (let i = startIdx; i < branches.length; i++) {
117
135
  const branch = branches[i];
118
136
  if (branch === undefined) continue;
119
- const newBase = i === 0 ? `origin/${trunk}` : (branches[i - 1] ?? `origin/${trunk}`);
137
+ const newBase = i === 0 ? originTrunk : (branches[i - 1] ?? originTrunk);
120
138
 
121
139
  // Compute old base (merge-base of this branch and its parent) before rebasing
122
140
  const oldBase = yield* git
@@ -158,7 +176,7 @@ export const sync = Command.make("sync", {
158
176
  // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
159
177
  yield* Console.log(JSON.stringify({ branches: results }, null, 2));
160
178
  } else {
161
- yield* success("Stack synced");
179
+ yield* success(`Stack synced (including trunk ${trunk})`);
162
180
  }
163
181
  }),
164
182
  ),
@@ -1,5 +1,5 @@
1
1
  import { existsSync } from "node:fs";
2
- import { Effect, Layer, ServiceMap } from "effect";
2
+ import { Effect, Layer, Option, ServiceMap } from "effect";
3
3
  import { GitError } from "../errors/index.js";
4
4
 
5
5
  export class GitService extends ServiceMap.Service<
@@ -8,6 +8,7 @@ export class GitService extends ServiceMap.Service<
8
8
  readonly currentBranch: () => Effect.Effect<string, GitError>;
9
9
  readonly listBranches: () => Effect.Effect<string[], GitError>;
10
10
  readonly branchExists: (name: string) => Effect.Effect<boolean, GitError>;
11
+ readonly remoteDefaultBranch: (remote?: string) => Effect.Effect<Option.Option<string>, never>;
11
12
  readonly createBranch: (name: string, from?: string) => Effect.Effect<void, GitError>;
12
13
  readonly deleteBranch: (name: string, force?: boolean) => Effect.Effect<void, GitError>;
13
14
  readonly checkout: (name: string) => Effect.Effect<void, GitError>;
@@ -113,6 +114,12 @@ export class GitService extends ServiceMap.Service<
113
114
  Effect.catchTag("GitError", () => Effect.succeed(false)),
114
115
  ),
115
116
 
117
+ remoteDefaultBranch: (remote = "origin") =>
118
+ run(["symbolic-ref", "--quiet", "--short", `refs/remotes/${remote}/HEAD`]).pipe(
119
+ Effect.map((ref) => Option.some(ref.replace(new RegExp(`^${remote}/`), ""))),
120
+ Effect.catchTag("GitError", () => Effect.succeed(Option.none())),
121
+ ),
122
+
116
123
  createBranch: (name, from) => {
117
124
  const args = from !== undefined ? ["checkout", "-b", name, from] : ["checkout", "-b", name];
118
125
  return run(args).pipe(Effect.asVoid);
@@ -183,6 +190,7 @@ export class GitService extends ServiceMap.Service<
183
190
  currentBranch: () => Effect.succeed("main"),
184
191
  listBranches: () => Effect.succeed([]),
185
192
  branchExists: () => Effect.succeed(false),
193
+ remoteDefaultBranch: () => Effect.succeed(Option.none()),
186
194
  createBranch: () => Effect.void,
187
195
  deleteBranch: () => Effect.void,
188
196
  checkout: () => Effect.void,
@@ -1,4 +1,4 @@
1
- import { Effect, Layer, Ref, Schema, ServiceMap } from "effect";
1
+ import { Effect, Layer, Option, Ref, Schema, ServiceMap } from "effect";
2
2
  import { rename } from "node:fs/promises";
3
3
  import type { GitError } from "../errors/index.js";
4
4
  import { StackError } from "../errors/index.js";
@@ -38,6 +38,7 @@ export class StackService extends ServiceMap.Service<
38
38
  readonly findBranchStack: (
39
39
  branch: string,
40
40
  ) => Effect.Effect<{ name: string; stack: Stack } | null, StackError>;
41
+ readonly detectTrunkCandidate: () => Effect.Effect<Option.Option<string>, never>;
41
42
  readonly getTrunk: () => Effect.Effect<string, StackError>;
42
43
  readonly setTrunk: (name: string) => Effect.Effect<void, StackError>;
43
44
  }
@@ -62,15 +63,27 @@ export class StackService extends ServiceMap.Service<
62
63
  const decodeStackFile = Schema.decodeUnknownEffect(StackFileJson);
63
64
  const encodeStackFile = Schema.encodeEffect(StackFileJson);
64
65
 
65
- const detectTrunk = Effect.fn("StackService.detectTrunk")(function* () {
66
- // Check common default branch names
66
+ const detectTrunkCandidate = Effect.fn("StackService.detectTrunkCandidate")(function* () {
67
+ const remoteDefault = yield* git.remoteDefaultBranch("origin");
68
+ if (Option.isSome(remoteDefault)) {
69
+ const exists = yield* git
70
+ .branchExists(remoteDefault.value)
71
+ .pipe(Effect.catchTag("GitError", () => Effect.succeed(false)));
72
+ if (exists) return remoteDefault;
73
+ }
74
+
67
75
  for (const candidate of ["main", "master", "develop"]) {
68
76
  const exists = yield* git
69
77
  .branchExists(candidate)
70
78
  .pipe(Effect.catchTag("GitError", () => Effect.succeed(false)));
71
- if (exists) return candidate;
79
+ if (exists) return Option.some(candidate);
72
80
  }
73
- return "main";
81
+ return Option.none();
82
+ });
83
+
84
+ const detectTrunk = Effect.fn("StackService.detectTrunk")(function* () {
85
+ const candidate = yield* detectTrunkCandidate();
86
+ return Option.getOrElse(candidate, () => "main");
74
87
  });
75
88
 
76
89
  const load = Effect.fn("StackService.load")(function* () {
@@ -137,6 +150,7 @@ export class StackService extends ServiceMap.Service<
137
150
 
138
151
  findBranchStack: (branch: string) =>
139
152
  load().pipe(Effect.map((data) => findBranchStack(data, branch))),
153
+ detectTrunkCandidate: () => detectTrunkCandidate(),
140
154
 
141
155
  currentStack: Effect.fn("StackService.currentStack")(function* () {
142
156
  const branch = yield* git.currentBranch();
@@ -226,7 +240,10 @@ export class StackService extends ServiceMap.Service<
226
240
  }),
227
241
  );
228
242
 
229
- static layerTest = (data?: StackFile, options?: { currentBranch?: string }) => {
243
+ static layerTest = (
244
+ data?: StackFile,
245
+ options?: { currentBranch?: string; detectTrunkCandidate?: Option.Option<string> },
246
+ ) => {
230
247
  const initial = data ?? emptyStackFile;
231
248
  return Layer.effect(
232
249
  StackService,
@@ -297,6 +314,12 @@ export class StackService extends ServiceMap.Service<
297
314
  }));
298
315
  }),
299
316
 
317
+ detectTrunkCandidate: () =>
318
+ Effect.succeed(
319
+ options?.detectTrunkCandidate !== undefined
320
+ ? options.detectTrunkCandidate
321
+ : Option.some(initial.trunk),
322
+ ),
300
323
  getTrunk: () => Ref.get(ref).pipe(Effect.map((d) => d.trunk)),
301
324
  setTrunk: (name: string) => Ref.update(ref, (d) => ({ ...d, trunk: name })),
302
325
  };