@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 +0 -0
- package/package.json +10 -10
- package/skills/stacked/SKILL.md +5 -5
- package/src/commands/adopt.ts +2 -1
- package/src/commands/amend.ts +10 -3
- package/src/commands/clean.ts +9 -5
- package/src/commands/detect.ts +23 -8
- package/src/commands/helpers/detect.ts +22 -0
- package/src/commands/init.ts +4 -2
- package/src/commands/list.ts +8 -8
- package/src/commands/reorder.ts +10 -0
- package/src/commands/stacks.ts +4 -5
- package/src/commands/status.ts +8 -6
- package/src/commands/submit.ts +34 -7
- package/src/commands/sync.ts +5 -2
- package/src/errors/index.ts +7 -1
- package/src/main.ts +20 -12
- package/src/services/Git.ts +6 -1
- package/src/ui.ts +106 -32
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.
|
|
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.
|
|
36
|
-
"effect": "4.0.0-beta.
|
|
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.
|
|
41
|
-
"@changesets/cli": "^2.
|
|
42
|
-
"@effect/language-service": "^0.
|
|
43
|
-
"@types/bun": "^1.3.
|
|
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.
|
|
47
|
-
"oxfmt": "^0.
|
|
48
|
-
"oxlint": "^1.
|
|
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
|
}
|
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 + 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
|
|
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
|
|
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`)
|
package/src/commands/adopt.ts
CHANGED
|
@@ -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*
|
|
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
|
),
|
package/src/commands/amend.ts
CHANGED
|
@@ -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
|
|
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));
|
package/src/commands/clean.ts
CHANGED
|
@@ -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*
|
|
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*
|
|
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*
|
|
150
|
-
|
|
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*
|
|
161
|
+
const line = yield* dim(` ${branch} (${stackName})`);
|
|
162
|
+
yield* Console.error(line);
|
|
159
163
|
}
|
|
160
164
|
}
|
|
161
165
|
}
|
package/src/commands/detect.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
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 =
|
|
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:
|
|
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
|
+
};
|
package/src/commands/init.ts
CHANGED
|
@@ -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 =
|
|
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
|
|
package/src/commands/list.ts
CHANGED
|
@@ -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
|
}
|
package/src/commands/reorder.ts
CHANGED
|
@@ -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({
|
package/src/commands/stacks.ts
CHANGED
|
@@ -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
|
-
|
|
52
|
-
|
|
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"));
|
package/src/commands/status.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
46
|
-
|
|
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"));
|
package/src/commands/submit.ts
CHANGED
|
@@ -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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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*
|
|
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)
|
|
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
|
|
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
|
|
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;
|
package/src/commands/sync.ts
CHANGED
|
@@ -32,7 +32,9 @@ export const sync = Command.make("sync", {
|
|
|
32
32
|
json: jsonFlag,
|
|
33
33
|
dryRun: dryRunFlag,
|
|
34
34
|
}).pipe(
|
|
35
|
-
Command.withDescription(
|
|
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(
|
package/src/errors/index.ts
CHANGED
|
@@ -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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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.
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
);
|
package/src/services/Git.ts
CHANGED
|
@@ -93,7 +93,12 @@ export class GitService extends ServiceMap.Service<
|
|
|
93
93
|
),
|
|
94
94
|
|
|
95
95
|
listBranches: () =>
|
|
96
|
-
run([
|
|
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
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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*
|
|
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*
|
|
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*
|
|
120
|
+
const colors = yield* getColors();
|
|
121
|
+
yield* write(colors.cyan(msg));
|
|
100
122
|
});
|
|
101
123
|
|
|
102
|
-
export const error = (msg: string)
|
|
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*
|
|
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)
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
export const
|
|
159
|
-
|
|
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)
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
};
|