@cvr/stacked 0.4.4 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/stacked +0 -0
- package/package.json +1 -1
- package/skills/stacked/SKILL.md +27 -13
- package/src/commands/adopt.ts +9 -0
- package/src/commands/amend.ts +54 -18
- package/src/commands/clean.ts +6 -4
- package/src/commands/create.ts +4 -0
- package/src/commands/delete.ts +1 -0
- package/src/commands/detect.ts +68 -9
- package/src/commands/doctor.ts +34 -5
- package/src/commands/helpers/pr-metadata.ts +12 -4
- package/src/commands/index.ts +1 -1
- package/src/commands/init.ts +19 -10
- package/src/commands/log.ts +12 -2
- package/src/commands/reorder.ts +2 -0
- package/src/commands/stacks.ts +2 -2
- package/src/commands/submit.ts +33 -6
- package/src/commands/sync.ts +130 -35
- package/src/errors/index.ts +2 -0
- package/src/main.ts +15 -5
- package/src/services/Git.ts +39 -4
- package/src/services/GitEs.ts +104 -12
- package/src/services/Stack.ts +62 -18
package/bin/stacked
CHANGED
|
Binary file
|
package/package.json
CHANGED
package/skills/stacked/SKILL.md
CHANGED
|
@@ -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 +
|
|
48
|
+
| `stacked sync` | Fetch + sync stack on trunk (`--from`, `--dry-run`, `--rebase-only`, `--json`) |
|
|
49
49
|
| `stacked detect` | Detect branch chains and register as stacks (`--dry-run`, `--json`) |
|
|
50
50
|
| `stacked clean` | Remove merged branches + remote branches (`--dry-run`, `--json`) |
|
|
51
51
|
| `stacked delete <name>` | Remove branch from stack + git + remote (`--keep-remote`, `--force`, `--dry-run`, `--json`) |
|
|
@@ -143,23 +143,33 @@ All navigation commands support `--json` for structured output. `checkout` falls
|
|
|
143
143
|
|
|
144
144
|
## Syncing / Rebasing / Pushing
|
|
145
145
|
|
|
146
|
-
Fetch latest trunk,
|
|
146
|
+
Fetch latest trunk, then incrementally sync the stack bottom-to-top using fork-point tracking:
|
|
147
147
|
|
|
148
148
|
```sh
|
|
149
149
|
stacked sync
|
|
150
|
-
stacked sync --dry-run
|
|
151
|
-
stacked sync --
|
|
150
|
+
stacked sync --dry-run # preview sync plan with predicted actions per branch
|
|
151
|
+
stacked sync --rebase-only # force rebase path (skip tree-merge)
|
|
152
|
+
stacked sync --json # structured output: { branches: [{ name, action, base }] }
|
|
152
153
|
```
|
|
153
154
|
|
|
154
|
-
|
|
155
|
+
**How sync works:**
|
|
156
|
+
|
|
157
|
+
1. Fetch and rebase trunk onto `origin/trunk`
|
|
158
|
+
2. For each branch, check if parent moved since last sync (`syncedOnto` metadata)
|
|
159
|
+
3. If parent unchanged → skip (no push needed)
|
|
160
|
+
4. If parent changed → try tree-merge fast path (es-git: `mergeTrees`, creates merge commit, no replay)
|
|
161
|
+
5. If tree-merge conflicts → fall back to rebase with corrected fork-point as `oldBase`
|
|
162
|
+
6. Force-push changed branches, update `syncedOnto` metadata
|
|
163
|
+
|
|
164
|
+
After mid-stack changes, sync only the branches above a specific point:
|
|
155
165
|
|
|
156
166
|
```sh
|
|
157
167
|
stacked checkout feat-auth
|
|
158
168
|
# ... make changes, commit ...
|
|
159
|
-
stacked sync --from feat-auth #
|
|
169
|
+
stacked sync --from feat-auth # syncs only children of feat-auth
|
|
160
170
|
```
|
|
161
171
|
|
|
162
|
-
**Note:** `sync` requires a clean working tree — commit or stash before running (except with `--dry-run`). On rebase conflict, the rebase is left in progress so you can resolve it:
|
|
172
|
+
**Note:** `sync` requires a clean working tree — commit or stash before running (except with `--dry-run`). On rebase conflict (fallback path), the rebase is left in progress so you can resolve it:
|
|
163
173
|
|
|
164
174
|
```sh
|
|
165
175
|
# On conflict:
|
|
@@ -311,10 +321,11 @@ Usage errors (invalid args, bad state) exit code 2. Operational errors (git/gh f
|
|
|
311
321
|
|
|
312
322
|
## Data Model
|
|
313
323
|
|
|
314
|
-
Stack metadata lives in `.git/stacked.json`. Each branch
|
|
324
|
+
Stack metadata lives in `.git/stacked.json`. Each branch record stores:
|
|
315
325
|
|
|
316
|
-
- `
|
|
317
|
-
- `
|
|
326
|
+
- `stack` — which stack it belongs to
|
|
327
|
+
- `parent` — parent branch name (null for root)
|
|
328
|
+
- `syncedOnto` — (optional) the parent branch tip SHA at last sync, used for incremental sync
|
|
318
329
|
|
|
319
330
|
Trunk is auto-detected on first use from `origin/HEAD` when available, then falls back to local `main`, `master`, or `develop`. Override with `stacked trunk <name>`.
|
|
320
331
|
|
|
@@ -356,8 +367,10 @@ stacked down # go to previous branch
|
|
|
356
367
|
## Gotchas
|
|
357
368
|
|
|
358
369
|
- `stacked sync` requires a clean working tree — commit or stash first (except `--dry-run`)
|
|
359
|
-
- `stacked sync`
|
|
360
|
-
- `stacked sync`
|
|
370
|
+
- `stacked sync` uses tree-merge by default (es-git); falls back to rebase on conflict or CLI backend
|
|
371
|
+
- `stacked sync` skips branches whose parent hasn't moved since last sync
|
|
372
|
+
- `stacked sync` leaves rebase in progress on conflict (fallback path) — resolve with `git rebase --continue`, then resume with `stacked sync --from <parent>`
|
|
373
|
+
- `syncedOnto` metadata may become stale if branches are rebased outside `stacked` — first sync after that falls back to `merge-base`
|
|
361
374
|
- `stacked submit` force-pushes by default (use `--no-force` to disable)
|
|
362
375
|
- `stacked submit` and `stacked clean` require `gh` CLI authenticated (`gh auth login`)
|
|
363
376
|
- PRs target parent branches, not trunk — this is intentional for stacked review
|
|
@@ -369,4 +382,5 @@ stacked down # go to previous branch
|
|
|
369
382
|
- Detached HEAD is detected and produces a clear error — checkout a branch first
|
|
370
383
|
- Stack file writes are atomic (write to tmp, then rename) to prevent corruption
|
|
371
384
|
- `create` and `adopt` are idempotent — safe to re-run after transient failures
|
|
372
|
-
- Use `stacked doctor` to detect and fix metadata drift (stale branches, missing trunk)
|
|
385
|
+
- Use `stacked doctor` to detect and fix metadata drift (stale branches, missing trunk, stale syncedOnto)
|
|
386
|
+
- `stacked doctor --fix` clears stale `syncedOnto` entries pointing at non-existent commits
|
package/src/commands/adopt.ts
CHANGED
|
@@ -87,6 +87,15 @@ export const adopt = Command.make("adopt", {
|
|
|
87
87
|
yield* stacks.addBranch(result.name, branch, afterBranch);
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
// Record fork-point for incremental sync based on the branch's merge-base with its parent
|
|
91
|
+
const parentRef = Option.isSome(after) ? after.value : yield* git.currentBranch();
|
|
92
|
+
const parentTip = yield* git
|
|
93
|
+
.mergeBase(branch, parentRef)
|
|
94
|
+
.pipe(Effect.catchTag("GitError", () => git.revParse(parentRef)));
|
|
95
|
+
yield* stacks
|
|
96
|
+
.updateSyncedOnto(branch, parentTip)
|
|
97
|
+
.pipe(Effect.catchTag("StackError", () => Effect.void));
|
|
98
|
+
|
|
90
99
|
if (json) {
|
|
91
100
|
const stackResult = yield* stacks.currentStack();
|
|
92
101
|
const stackName = stackResult?.name ?? branch;
|
package/src/commands/amend.ts
CHANGED
|
@@ -61,34 +61,70 @@ export const amend = Command.make("amend", {
|
|
|
61
61
|
return;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
//
|
|
64
|
+
// Sync children using fork-point-aware algorithm
|
|
65
65
|
const children = branches.slice(idx + 1);
|
|
66
66
|
const synced: string[] = [];
|
|
67
|
+
const data = yield* stacks.load();
|
|
68
|
+
const mergedSet = new Set(data.mergedBranches);
|
|
67
69
|
|
|
68
70
|
yield* Effect.gen(function* () {
|
|
69
71
|
for (let i = 0; i < children.length; i++) {
|
|
70
72
|
const branch = children[i];
|
|
71
73
|
if (branch === undefined) continue;
|
|
72
|
-
|
|
74
|
+
// Compute effective base, skipping merged branches (same as sync)
|
|
75
|
+
let newBase = fromBranch;
|
|
76
|
+
for (let j = i - 1; j >= 0; j--) {
|
|
77
|
+
const candidate = children[j];
|
|
78
|
+
if (candidate !== undefined && !mergedSet.has(candidate)) {
|
|
79
|
+
newBase = candidate;
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
73
83
|
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
84
|
+
const newBaseTip = yield* git.revParse(newBase);
|
|
85
|
+
const branchHead = yield* git.revParse(branch);
|
|
86
|
+
const syncedOnto = yield* stacks.getSyncedOnto(branch);
|
|
77
87
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
88
|
+
// Resolve old base: prefer recorded fork-point, fall back to merge-base
|
|
89
|
+
const oldBase =
|
|
90
|
+
syncedOnto ??
|
|
91
|
+
(yield* git
|
|
92
|
+
.mergeBase(branch, newBase)
|
|
93
|
+
.pipe(Effect.catchTag("GitError", () => Effect.succeed(newBase))));
|
|
94
|
+
|
|
95
|
+
// Try tree-merge fast path
|
|
96
|
+
const mergeResult = yield* git
|
|
97
|
+
.treeMergeSync({
|
|
98
|
+
branch,
|
|
99
|
+
branchHead,
|
|
100
|
+
oldBase,
|
|
101
|
+
newBase: newBaseTip,
|
|
102
|
+
message: `sync: incorporate changes from ${newBase}`,
|
|
103
|
+
})
|
|
104
|
+
.pipe(
|
|
105
|
+
Effect.catchTag("GitError", () => Effect.succeed({ action: "conflict" as const })),
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
if (mergeResult.action === "merged" || mergeResult.action === "up-to-date") {
|
|
109
|
+
yield* stacks.updateSyncedOnto(branch, newBaseTip);
|
|
110
|
+
} else {
|
|
111
|
+
// Conflict — fall back to rebase with corrected oldBase
|
|
112
|
+
yield* git.checkout(branch);
|
|
113
|
+
yield* withSpinner(
|
|
114
|
+
`Rebasing ${branch} onto ${newBase}`,
|
|
115
|
+
git.rebaseOnto(branch, newBase, oldBase),
|
|
116
|
+
).pipe(
|
|
117
|
+
Effect.catchTag("GitError", (e) =>
|
|
118
|
+
Effect.fail(
|
|
119
|
+
new StackError({
|
|
120
|
+
code: ErrorCode.REBASE_CONFLICT,
|
|
121
|
+
message: `Rebase conflict on ${branch}: ${e.message}\n\nResolve conflicts, then run:\n git rebase --continue`,
|
|
122
|
+
}),
|
|
123
|
+
),
|
|
89
124
|
),
|
|
90
|
-
)
|
|
91
|
-
|
|
125
|
+
);
|
|
126
|
+
yield* stacks.updateSyncedOnto(branch, newBaseTip);
|
|
127
|
+
}
|
|
92
128
|
synced.push(branch);
|
|
93
129
|
}
|
|
94
130
|
}).pipe(
|
package/src/commands/clean.ts
CHANGED
|
@@ -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*
|
|
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`,
|
package/src/commands/create.ts
CHANGED
|
@@ -99,6 +99,10 @@ export const create = Command.make("create", {
|
|
|
99
99
|
yield* stacks.addBranch(stackName, name, baseBranch === trunk ? undefined : baseBranch);
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
// Record the fork-point for incremental sync
|
|
103
|
+
const baseTip = yield* git.revParse(baseBranch);
|
|
104
|
+
yield* stacks.updateSyncedOnto(name, baseTip);
|
|
105
|
+
|
|
102
106
|
if (json) {
|
|
103
107
|
// @effect-diagnostics-next-line effect/preferSchemaOverJson:off
|
|
104
108
|
yield* Console.log(
|
package/src/commands/delete.ts
CHANGED
package/src/commands/detect.ts
CHANGED
|
@@ -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*
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
181
|
-
|
|
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) {
|
package/src/commands/doctor.ts
CHANGED
|
@@ -8,7 +8,7 @@ const fixFlag = Flag.boolean("fix").pipe(Flag.withDescription("Auto-fix issues w
|
|
|
8
8
|
const jsonFlag = Flag.boolean("json").pipe(Flag.withDescription("Output as JSON"));
|
|
9
9
|
|
|
10
10
|
interface Finding {
|
|
11
|
-
type: "stale_branch" | "missing_trunk" | "duplicate_branch" | "parse_error";
|
|
11
|
+
type: "stale_branch" | "missing_trunk" | "duplicate_branch" | "stale_fork_point" | "parse_error";
|
|
12
12
|
message: string;
|
|
13
13
|
fixed: boolean;
|
|
14
14
|
}
|
|
@@ -66,12 +66,14 @@ export const doctor = Command.make("doctor", { fix: fixFlag, json: jsonFlag }).p
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
// Check 2: all tracked branches exist in git
|
|
69
|
+
const allGitBranches = yield* git
|
|
70
|
+
.listBranches()
|
|
71
|
+
.pipe(Effect.catchTag("GitError", () => Effect.succeed([] as string[])));
|
|
72
|
+
const gitBranchSet = new Set(allGitBranches);
|
|
73
|
+
|
|
69
74
|
for (const { name: stackName, stack } of stackEntries) {
|
|
70
75
|
for (const branch of stack.branches) {
|
|
71
|
-
|
|
72
|
-
.branchExists(branch)
|
|
73
|
-
.pipe(Effect.catchTag("GitError", () => Effect.succeed(false)));
|
|
74
|
-
if (!exists) {
|
|
76
|
+
if (!gitBranchSet.has(branch)) {
|
|
75
77
|
if (fix) {
|
|
76
78
|
yield* stacks.removeBranch(branch);
|
|
77
79
|
findings.push({
|
|
@@ -107,6 +109,33 @@ export const doctor = Command.make("doctor", { fix: fixFlag, json: jsonFlag }).p
|
|
|
107
109
|
}
|
|
108
110
|
}
|
|
109
111
|
|
|
112
|
+
// Check 4: syncedOnto entries point at valid commits
|
|
113
|
+
for (const [branch, record] of Object.entries(data.branches)) {
|
|
114
|
+
if (record.syncedOnto == null) continue;
|
|
115
|
+
const valid = yield* git.revParse(record.syncedOnto).pipe(
|
|
116
|
+
Effect.as(true),
|
|
117
|
+
Effect.catchTag("GitError", () => Effect.succeed(false)),
|
|
118
|
+
);
|
|
119
|
+
if (!valid) {
|
|
120
|
+
if (fix) {
|
|
121
|
+
yield* stacks
|
|
122
|
+
.updateSyncedOnto(branch, null)
|
|
123
|
+
.pipe(Effect.catchTag("StackError", () => Effect.void));
|
|
124
|
+
findings.push({
|
|
125
|
+
type: "stale_fork_point",
|
|
126
|
+
message: `Cleared stale syncedOnto for "${branch}" (commit ${record.syncedOnto.slice(0, 7)} no longer exists)`,
|
|
127
|
+
fixed: true,
|
|
128
|
+
});
|
|
129
|
+
} else {
|
|
130
|
+
findings.push({
|
|
131
|
+
type: "stale_fork_point",
|
|
132
|
+
message: `Branch "${branch}" has stale syncedOnto (commit ${record.syncedOnto.slice(0, 7)} no longer exists)`,
|
|
133
|
+
fixed: false,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
110
139
|
if (json) {
|
|
111
140
|
// @effect-diagnostics-next-line effect/preferSchemaOverJson:off
|
|
112
141
|
yield* Console.log(JSON.stringify({ findings }, null, 2));
|
|
@@ -99,17 +99,21 @@ export const refreshStackedPRBodies = ({
|
|
|
99
99
|
getUserBody?: (branch: string, idx: number) => string | undefined;
|
|
100
100
|
}) =>
|
|
101
101
|
Effect.gen(function* () {
|
|
102
|
-
const prEntries = yield* Effect.
|
|
103
|
-
branches
|
|
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
|
-
|
|
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
|
});
|
package/src/commands/index.ts
CHANGED
|
@@ -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
|
|
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" },
|
package/src/commands/init.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
);
|
package/src/commands/log.ts
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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 })
|
package/src/commands/reorder.ts
CHANGED
|
@@ -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
|
}
|
package/src/commands/stacks.ts
CHANGED
|
@@ -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*
|
|
27
|
+
yield* info("No stacks");
|
|
28
28
|
}
|
|
29
29
|
return;
|
|
30
30
|
}
|