@cvr/stacked 0.4.0 → 0.4.2
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 +10 -10
- package/skills/stacked/SKILL.md +11 -10
- 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/doctor.ts +15 -15
- package/src/commands/helpers/detect.ts +22 -0
- package/src/commands/index.ts +1 -1
- package/src/commands/init.ts +7 -3
- 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 +27 -6
- package/src/errors/index.ts +7 -1
- package/src/main.ts +20 -12
- package/src/services/Git.ts +15 -2
- package/src/services/Stack.ts +29 -6
- package/src/ui.ts +106 -32
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cvr/stacked",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
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`) |
|
|
@@ -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`.
|
|
@@ -140,13 +141,13 @@ stacked bottom # jump to bottom (trunk-adjacent)
|
|
|
140
141
|
|
|
141
142
|
All navigation commands support `--json` for structured output. `checkout` falls through to `git checkout` for branches not in any stack.
|
|
142
143
|
|
|
143
|
-
## Syncing / Rebasing
|
|
144
|
+
## Syncing / Rebasing / Pushing
|
|
144
145
|
|
|
145
|
-
Fetch latest trunk
|
|
146
|
+
Fetch latest trunk, rebase the entire stack bottom-to-top, then force-push each rebased branch (`--force-with-lease`):
|
|
146
147
|
|
|
147
148
|
```sh
|
|
148
149
|
stacked sync
|
|
149
|
-
stacked sync --dry-run # preview rebase plan without executing
|
|
150
|
+
stacked sync --dry-run # preview rebase/push plan without executing
|
|
150
151
|
stacked sync --json # structured output: { branches: [{ name, action, base }] }
|
|
151
152
|
```
|
|
152
153
|
|
|
@@ -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
|
|
|
@@ -355,13 +356,13 @@ stacked down # go to previous branch
|
|
|
355
356
|
## Gotchas
|
|
356
357
|
|
|
357
358
|
- `stacked sync` requires a clean working tree — commit or stash first (except `--dry-run`)
|
|
358
|
-
- `stacked sync` rebases bottom-to-top
|
|
359
|
+
- `stacked sync` rebases bottom-to-top and force-pushes each rebased branch with lease
|
|
359
360
|
- `stacked sync` leaves rebase in progress on conflict — resolve with `git rebase --continue`, then resume with `stacked sync --from <parent>`
|
|
360
361
|
- `stacked submit` force-pushes by default (use `--no-force` to disable)
|
|
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/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) {
|
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({
|
|
@@ -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/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
|
@@ -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
|
|
|
@@ -33,8 +35,10 @@ export const init = Command.make("init").pipe(
|
|
|
33
35
|
|
|
34
36
|
yield* Console.error(`Installed stacked skill to ${targetPath}`);
|
|
35
37
|
yield* Console.error("\nNext steps:");
|
|
36
|
-
yield* Console.error(" stacked trunk # verify/set your trunk branch");
|
|
37
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
|
+
);
|
|
38
42
|
}),
|
|
39
43
|
),
|
|
40
44
|
);
|
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,9 +32,11 @@ 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
|
-
{ command: "stacked sync", description: "
|
|
39
|
+
{ command: "stacked sync", description: "Sync local trunk, then rebase entire stack on trunk" },
|
|
38
40
|
{ command: "stacked sync --from feat-auth", description: "Resume from a specific branch" },
|
|
39
41
|
{ command: "stacked sync --dry-run", description: "Preview rebase plan" },
|
|
40
42
|
]),
|
|
@@ -44,6 +46,7 @@ export const sync = Command.make("sync", {
|
|
|
44
46
|
const stacks = yield* StackService;
|
|
45
47
|
|
|
46
48
|
const trunk = Option.isSome(trunkOpt) ? trunkOpt.value : yield* stacks.getTrunk();
|
|
49
|
+
const originTrunk = `origin/${trunk}`;
|
|
47
50
|
const currentBranch = yield* git.currentBranch();
|
|
48
51
|
|
|
49
52
|
if (!dryRun) {
|
|
@@ -87,13 +90,18 @@ export const sync = Command.make("sync", {
|
|
|
87
90
|
const results: SyncResult[] = [];
|
|
88
91
|
|
|
89
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
|
+
|
|
90
98
|
for (let i = startIdx; i < branches.length; i++) {
|
|
91
99
|
const branch = branches[i];
|
|
92
100
|
if (branch === undefined) continue;
|
|
93
|
-
const base = i === 0 ?
|
|
101
|
+
const base = i === 0 ? originTrunk : (branches[i - 1] ?? originTrunk);
|
|
94
102
|
results.push({ name: branch, action: "skipped", base });
|
|
95
103
|
if (!json) {
|
|
96
|
-
yield* Console.error(`Would rebase ${branch} onto ${base}`);
|
|
104
|
+
yield* Console.error(`Would rebase and force-push ${branch} onto ${base}`);
|
|
97
105
|
}
|
|
98
106
|
}
|
|
99
107
|
|
|
@@ -109,12 +117,24 @@ export const sync = Command.make("sync", {
|
|
|
109
117
|
}
|
|
110
118
|
|
|
111
119
|
yield* withSpinner(`Fetching ${trunk}`, git.fetch());
|
|
120
|
+
yield* git.checkout(trunk);
|
|
121
|
+
yield* withSpinner(`Rebasing ${trunk} onto ${originTrunk}`, git.rebase(originTrunk)).pipe(
|
|
122
|
+
Effect.catchTag("GitError", (e) =>
|
|
123
|
+
Effect.fail(
|
|
124
|
+
new StackError({
|
|
125
|
+
code: ErrorCode.REBASE_CONFLICT,
|
|
126
|
+
message: `Rebase conflict on ${trunk}: ${e.message}\n\nResolve conflicts, then run:\n git rebase --continue`,
|
|
127
|
+
}),
|
|
128
|
+
),
|
|
129
|
+
),
|
|
130
|
+
);
|
|
131
|
+
results.push({ name: trunk, action: "rebased", base: originTrunk });
|
|
112
132
|
|
|
113
133
|
yield* Effect.gen(function* () {
|
|
114
134
|
for (let i = startIdx; i < branches.length; i++) {
|
|
115
135
|
const branch = branches[i];
|
|
116
136
|
if (branch === undefined) continue;
|
|
117
|
-
const newBase = i === 0 ?
|
|
137
|
+
const newBase = i === 0 ? originTrunk : (branches[i - 1] ?? originTrunk);
|
|
118
138
|
|
|
119
139
|
// Compute old base (merge-base of this branch and its parent) before rebasing
|
|
120
140
|
const oldBase = yield* git
|
|
@@ -137,6 +157,7 @@ export const sync = Command.make("sync", {
|
|
|
137
157
|
);
|
|
138
158
|
}),
|
|
139
159
|
);
|
|
160
|
+
yield* withSpinner(`Pushing ${branch}`, git.push(branch, { force: true }));
|
|
140
161
|
results.push({ name: branch, action: "rebased", base: newBase });
|
|
141
162
|
}
|
|
142
163
|
}).pipe(
|
|
@@ -155,7 +176,7 @@ export const sync = Command.make("sync", {
|
|
|
155
176
|
// @effect-diagnostics-next-line effect/preferSchemaOverJson:off
|
|
156
177
|
yield* Console.log(JSON.stringify({ branches: results }, null, 2));
|
|
157
178
|
} else {
|
|
158
|
-
yield* success(
|
|
179
|
+
yield* success(`Stack synced (including trunk ${trunk})`);
|
|
159
180
|
}
|
|
160
181
|
}),
|
|
161
182
|
),
|
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
|
@@ -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>;
|
|
@@ -93,7 +94,12 @@ export class GitService extends ServiceMap.Service<
|
|
|
93
94
|
),
|
|
94
95
|
|
|
95
96
|
listBranches: () =>
|
|
96
|
-
run([
|
|
97
|
+
run([
|
|
98
|
+
"for-each-ref",
|
|
99
|
+
"--sort=-committerdate",
|
|
100
|
+
"--format=%(refname:short)",
|
|
101
|
+
"refs/heads",
|
|
102
|
+
]).pipe(
|
|
97
103
|
Effect.map((output) =>
|
|
98
104
|
output
|
|
99
105
|
.split("\n")
|
|
@@ -108,6 +114,12 @@ export class GitService extends ServiceMap.Service<
|
|
|
108
114
|
Effect.catchTag("GitError", () => Effect.succeed(false)),
|
|
109
115
|
),
|
|
110
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
|
+
|
|
111
123
|
createBranch: (name, from) => {
|
|
112
124
|
const args = from !== undefined ? ["checkout", "-b", name, from] : ["checkout", "-b", name];
|
|
113
125
|
return run(args).pipe(Effect.asVoid);
|
|
@@ -178,6 +190,7 @@ export class GitService extends ServiceMap.Service<
|
|
|
178
190
|
currentBranch: () => Effect.succeed("main"),
|
|
179
191
|
listBranches: () => Effect.succeed([]),
|
|
180
192
|
branchExists: () => Effect.succeed(false),
|
|
193
|
+
remoteDefaultBranch: () => Effect.succeed(Option.none()),
|
|
181
194
|
createBranch: () => Effect.void,
|
|
182
195
|
deleteBranch: () => Effect.void,
|
|
183
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
|
};
|
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
|
};
|