@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 +5 -4
- package/bin/stacked +0 -0
- package/package.json +1 -1
- package/skills/stacked/SKILL.md +6 -5
- package/src/commands/doctor.ts +15 -15
- package/src/commands/index.ts +1 -1
- package/src/commands/init.ts +3 -1
- package/src/commands/sync.ts +24 -6
- package/src/services/Git.ts +9 -1
- package/src/services/Stack.ts +29 -6
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
|
|
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
package/skills/stacked/SKILL.md
CHANGED
|
@@ -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
|
|
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>`
|
|
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
|
package/src/commands/doctor.ts
CHANGED
|
@@ -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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
48
|
-
|
|
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({
|
package/src/commands/index.ts
CHANGED
|
@@ -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: "
|
|
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
|
);
|
package/src/commands/init.ts
CHANGED
|
@@ -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
|
);
|
package/src/commands/sync.ts
CHANGED
|
@@ -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: "
|
|
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 ?
|
|
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 ?
|
|
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(
|
|
179
|
+
yield* success(`Stack synced (including trunk ${trunk})`);
|
|
162
180
|
}
|
|
163
181
|
}),
|
|
164
182
|
),
|
package/src/services/Git.ts
CHANGED
|
@@ -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,
|
package/src/services/Stack.ts
CHANGED
|
@@ -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
|
|
66
|
-
|
|
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
|
|
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 = (
|
|
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
|
};
|