@cvr/stacked 0.3.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/README.md +74 -23
- package/bin/stacked +0 -0
- package/package.json +11 -10
- package/scripts/build.ts +8 -1
- package/skills/stacked/SKILL.md +210 -43
- package/src/commands/adopt.ts +71 -8
- package/src/commands/amend.ts +114 -0
- package/src/commands/bottom.ts +26 -8
- package/src/commands/checkout.ts +25 -6
- package/src/commands/clean.ts +110 -27
- package/src/commands/create.ts +74 -12
- package/src/commands/delete.ts +92 -24
- package/src/commands/detect.ts +95 -54
- package/src/commands/doctor.ts +124 -0
- package/src/commands/down.ts +62 -0
- package/src/commands/helpers/detect.ts +22 -0
- package/src/commands/helpers/validate.ts +61 -0
- package/src/commands/index.ts +27 -1
- package/src/commands/init.ts +42 -0
- package/src/commands/list.ts +61 -22
- package/src/commands/log.ts +32 -6
- package/src/commands/rename.ts +49 -0
- package/src/commands/reorder.ts +103 -0
- package/src/commands/split.ts +108 -0
- package/src/commands/stacks.ts +32 -7
- package/src/commands/status.ts +57 -0
- package/src/commands/submit.ts +272 -16
- package/src/commands/sync.ts +131 -20
- package/src/commands/top.ts +24 -8
- package/src/commands/trunk.ts +37 -6
- package/src/commands/up.ts +55 -0
- package/src/errors/index.ts +37 -1
- package/src/global.d.ts +2 -0
- package/src/main.ts +78 -3
- package/src/services/Git.ts +96 -31
- package/src/services/GitHub.ts +56 -18
- package/src/services/Stack.ts +65 -58
- package/src/ui.ts +247 -0
package/src/commands/delete.ts
CHANGED
|
@@ -2,37 +2,50 @@ import { Argument, 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 { StackError } from "../errors/index.js";
|
|
5
|
+
import { ErrorCode, StackError } from "../errors/index.js";
|
|
6
|
+
import { confirm } from "../ui.js";
|
|
6
7
|
|
|
7
|
-
const nameArg = Argument.string("name");
|
|
8
|
-
const forceFlag = Flag.boolean("force").pipe(
|
|
8
|
+
const nameArg = Argument.string("name").pipe(Argument.withDescription("Branch name to delete"));
|
|
9
|
+
const forceFlag = Flag.boolean("force").pipe(
|
|
10
|
+
Flag.withAlias("f"),
|
|
11
|
+
Flag.withDescription("Delete even if branch has children in the stack"),
|
|
12
|
+
);
|
|
13
|
+
const keepRemoteFlag = Flag.boolean("keep-remote").pipe(
|
|
14
|
+
Flag.withDescription("Don't delete the remote branch"),
|
|
15
|
+
);
|
|
16
|
+
const jsonFlag = Flag.boolean("json").pipe(Flag.withDescription("Output as JSON"));
|
|
17
|
+
const dryRunFlag = Flag.boolean("dry-run").pipe(
|
|
18
|
+
Flag.withDescription("Show what would happen without making changes"),
|
|
19
|
+
);
|
|
9
20
|
|
|
10
|
-
export const deleteCmd = Command.make("delete", {
|
|
21
|
+
export const deleteCmd = Command.make("delete", {
|
|
22
|
+
name: nameArg,
|
|
23
|
+
force: forceFlag,
|
|
24
|
+
keepRemote: keepRemoteFlag,
|
|
25
|
+
json: jsonFlag,
|
|
26
|
+
dryRun: dryRunFlag,
|
|
27
|
+
}).pipe(
|
|
11
28
|
Command.withDescription("Remove branch from stack and delete git branch"),
|
|
12
|
-
Command.
|
|
29
|
+
Command.withExamples([
|
|
30
|
+
{ command: "stacked delete feat-old", description: "Delete a leaf branch" },
|
|
31
|
+
{ command: "stacked delete feat-mid --force", description: "Force delete a mid-stack branch" },
|
|
32
|
+
]),
|
|
33
|
+
Command.withHandler(({ name, force, keepRemote, json, dryRun }) =>
|
|
13
34
|
Effect.gen(function* () {
|
|
14
35
|
const git = yield* GitService;
|
|
15
36
|
const stacks = yield* StackService;
|
|
16
37
|
|
|
17
38
|
const currentBranch = yield* git.currentBranch();
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
let stackName: string | null = null;
|
|
21
|
-
for (const [sName, stack] of Object.entries(data.stacks)) {
|
|
22
|
-
if (stack.branches.includes(name)) {
|
|
23
|
-
stackName = sName;
|
|
24
|
-
break;
|
|
25
|
-
}
|
|
26
|
-
}
|
|
39
|
+
const trunk = yield* stacks.getTrunk();
|
|
27
40
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
return yield* new StackError({ message: `Stack "${stackName}" not found` });
|
|
41
|
+
const result = yield* stacks.findBranchStack(name);
|
|
42
|
+
if (result === null) {
|
|
43
|
+
return yield* new StackError({
|
|
44
|
+
code: ErrorCode.BRANCH_NOT_FOUND,
|
|
45
|
+
message: `Branch "${name}" not found in any stack`,
|
|
46
|
+
});
|
|
35
47
|
}
|
|
48
|
+
const { name: stackName, stack } = result;
|
|
36
49
|
const idx = stack.branches.indexOf(name);
|
|
37
50
|
|
|
38
51
|
if (idx < stack.branches.length - 1 && !force) {
|
|
@@ -41,15 +54,70 @@ export const deleteCmd = Command.make("delete", { name: nameArg, force: forceFla
|
|
|
41
54
|
});
|
|
42
55
|
}
|
|
43
56
|
|
|
57
|
+
const hadChildren = idx < stack.branches.length - 1;
|
|
58
|
+
const willDeleteRemote = !keepRemote;
|
|
59
|
+
|
|
60
|
+
if (dryRun) {
|
|
61
|
+
if (json) {
|
|
62
|
+
// @effect-diagnostics-next-line effect/preferSchemaOverJson:off
|
|
63
|
+
yield* Console.log(
|
|
64
|
+
JSON.stringify(
|
|
65
|
+
{ branch: name, stack: stackName, hadChildren, deleteRemote: willDeleteRemote },
|
|
66
|
+
null,
|
|
67
|
+
2,
|
|
68
|
+
),
|
|
69
|
+
);
|
|
70
|
+
} else {
|
|
71
|
+
yield* Console.error(`Would delete branch "${name}" from stack "${stackName}"`);
|
|
72
|
+
if (hadChildren) {
|
|
73
|
+
yield* Console.error("Warning: branch has children that would be orphaned");
|
|
74
|
+
}
|
|
75
|
+
if (willDeleteRemote) {
|
|
76
|
+
yield* Console.error("Would also delete remote branch");
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const confirmed = yield* confirm(
|
|
83
|
+
`Delete branch "${name}"${keepRemote ? "" : " (local + remote)"}?`,
|
|
84
|
+
);
|
|
85
|
+
if (!confirmed) {
|
|
86
|
+
yield* Console.error("Aborted");
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
44
90
|
if (currentBranch === name) {
|
|
45
|
-
const
|
|
91
|
+
const clean = yield* git.isClean();
|
|
92
|
+
if (!clean) {
|
|
93
|
+
return yield* new StackError({
|
|
94
|
+
code: ErrorCode.DIRTY_WORKTREE,
|
|
95
|
+
message:
|
|
96
|
+
"Working tree has uncommitted changes. Commit or stash before deleting the current branch.",
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
const parent = idx === 0 ? trunk : (stack.branches[idx - 1] ?? trunk);
|
|
46
100
|
yield* git.checkout(parent);
|
|
47
101
|
}
|
|
48
102
|
|
|
49
|
-
yield* stacks.removeBranch(stackName, name);
|
|
50
103
|
yield* git.deleteBranch(name, force);
|
|
104
|
+
yield* stacks.removeBranch(stackName, name);
|
|
105
|
+
|
|
106
|
+
if (willDeleteRemote) {
|
|
107
|
+
yield* git.deleteRemoteBranch(name).pipe(Effect.catchTag("GitError", () => Effect.void));
|
|
108
|
+
}
|
|
51
109
|
|
|
52
|
-
|
|
110
|
+
if (json) {
|
|
111
|
+
// @effect-diagnostics-next-line effect/preferSchemaOverJson:off
|
|
112
|
+
yield* Console.log(JSON.stringify({ deleted: name, hadChildren }, null, 2));
|
|
113
|
+
} else {
|
|
114
|
+
yield* Console.error(`Deleted ${name}`);
|
|
115
|
+
if (hadChildren) {
|
|
116
|
+
yield* Console.error(
|
|
117
|
+
"Warning: branch had children — commits unique to this branch may be lost if children don't include them. Run 'stacked sync' to rebase them onto the new parent.",
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
53
121
|
}),
|
|
54
122
|
),
|
|
55
123
|
);
|
package/src/commands/detect.ts
CHANGED
|
@@ -2,12 +2,21 @@ 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 { success, warn, info } from "../ui.js";
|
|
6
|
+
import { detectLimitConfig, limitUntrackedBranches } from "./helpers/detect.js";
|
|
5
7
|
|
|
6
|
-
const dryRunFlag = Flag.boolean("dry-run")
|
|
8
|
+
const dryRunFlag = Flag.boolean("dry-run").pipe(
|
|
9
|
+
Flag.withDescription("Show what would be detected without making changes"),
|
|
10
|
+
);
|
|
11
|
+
const jsonFlag = Flag.boolean("json").pipe(Flag.withDescription("Output as JSON"));
|
|
7
12
|
|
|
8
|
-
export const detect = Command.make("detect", { dryRun: dryRunFlag }).pipe(
|
|
13
|
+
export const detect = Command.make("detect", { dryRun: dryRunFlag, json: jsonFlag }).pipe(
|
|
9
14
|
Command.withDescription("Detect and register branch stacks from git history"),
|
|
10
|
-
Command.
|
|
15
|
+
Command.withExamples([
|
|
16
|
+
{ command: "stacked detect", description: "Auto-discover and register stacks" },
|
|
17
|
+
{ command: "stacked detect --dry-run", description: "Preview what would be detected" },
|
|
18
|
+
]),
|
|
19
|
+
Command.withHandler(({ dryRun, json }) =>
|
|
11
20
|
Effect.gen(function* () {
|
|
12
21
|
const git = yield* GitService;
|
|
13
22
|
const stacks = yield* StackService;
|
|
@@ -18,62 +27,81 @@ export const detect = Command.make("detect", { dryRun: dryRunFlag }).pipe(
|
|
|
18
27
|
|
|
19
28
|
const data = yield* stacks.load();
|
|
20
29
|
const alreadyTracked = new Set(Object.values(data.stacks).flatMap((s) => [...s.branches]));
|
|
21
|
-
const
|
|
30
|
+
const untrackedAll = candidates.filter((b) => !alreadyTracked.has(b));
|
|
31
|
+
const detectLimit = yield* detectLimitConfig;
|
|
32
|
+
const { untracked, skipped } = limitUntrackedBranches(untrackedAll, detectLimit);
|
|
22
33
|
|
|
23
34
|
if (untracked.length === 0) {
|
|
24
|
-
yield* Console.
|
|
35
|
+
yield* Console.error("No untracked branches found");
|
|
25
36
|
return;
|
|
26
37
|
}
|
|
27
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
|
+
|
|
28
45
|
// Build parent map: for each branch, find its direct parent among other branches
|
|
29
46
|
// A parent is the closest ancestor — i.e., an ancestor that is not an ancestor of another ancestor
|
|
30
47
|
const childOf = new Map<string, string>();
|
|
31
48
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
49
|
+
yield* Effect.forEach(
|
|
50
|
+
untracked,
|
|
51
|
+
(branch) =>
|
|
52
|
+
Effect.gen(function* () {
|
|
53
|
+
// Check all potential ancestors (trunk + other untracked) in parallel
|
|
54
|
+
const potentialAncestors = [trunk, ...untracked.filter((b) => b !== branch)];
|
|
55
|
+
const ancestryResults = yield* Effect.forEach(
|
|
56
|
+
potentialAncestors,
|
|
57
|
+
(other) =>
|
|
58
|
+
git.isAncestor(other, branch).pipe(
|
|
59
|
+
Effect.catchTag("GitError", () => Effect.succeed(false)),
|
|
60
|
+
Effect.map((is) => [other, is] as const),
|
|
61
|
+
),
|
|
62
|
+
{ concurrency: 5 },
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const ancestors = ancestryResults.filter(([_, is]) => is).map(([name]) => name);
|
|
66
|
+
|
|
67
|
+
if (ancestors.length === 0) return;
|
|
68
|
+
|
|
69
|
+
// Find the closest ancestor — the one that is a descendant of all others
|
|
70
|
+
let closest = ancestors[0] ?? trunk;
|
|
71
|
+
for (let i = 1; i < ancestors.length; i++) {
|
|
72
|
+
const candidate = ancestors[i];
|
|
73
|
+
if (candidate === undefined) continue;
|
|
74
|
+
const candidateIsCloser = yield* git
|
|
75
|
+
.isAncestor(closest, candidate)
|
|
76
|
+
.pipe(Effect.catchTag("GitError", () => Effect.succeed(false)));
|
|
77
|
+
if (candidateIsCloser) closest = candidate;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
childOf.set(branch, closest);
|
|
81
|
+
}),
|
|
82
|
+
{ concurrency: 5 },
|
|
83
|
+
);
|
|
65
84
|
|
|
66
85
|
// Build linear chains from trunk
|
|
67
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
|
+
|
|
68
96
|
const chains: string[][] = [];
|
|
69
|
-
const roots =
|
|
97
|
+
const roots = childrenByParent.get(trunk) ?? [];
|
|
70
98
|
|
|
71
99
|
for (const root of roots) {
|
|
72
100
|
const chain = [root];
|
|
73
101
|
let current = root;
|
|
74
102
|
|
|
75
103
|
while (true) {
|
|
76
|
-
const children =
|
|
104
|
+
const children = childrenByParent.get(current) ?? [];
|
|
77
105
|
const child = children[0];
|
|
78
106
|
if (children.length === 1 && child !== undefined) {
|
|
79
107
|
chain.push(child);
|
|
@@ -87,38 +115,51 @@ export const detect = Command.make("detect", { dryRun: dryRunFlag }).pipe(
|
|
|
87
115
|
chains.push(chain);
|
|
88
116
|
}
|
|
89
117
|
|
|
118
|
+
// Report forks
|
|
119
|
+
const forkPoints = untracked.filter((b) => (childrenByParent.get(b)?.length ?? 0) > 1);
|
|
120
|
+
const forks = forkPoints.map((b) => ({
|
|
121
|
+
branch: b,
|
|
122
|
+
children: childrenByParent.get(b) ?? [],
|
|
123
|
+
}));
|
|
124
|
+
|
|
125
|
+
if (json) {
|
|
126
|
+
const stacksData = chains.map((chain) => ({ name: chain[0] ?? "", branches: chain }));
|
|
127
|
+
// @effect-diagnostics-next-line effect/preferSchemaOverJson:off
|
|
128
|
+
yield* Console.log(JSON.stringify({ stacks: stacksData, forks }, null, 2));
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
90
132
|
if (chains.length === 0) {
|
|
91
|
-
yield*
|
|
133
|
+
yield* info("No linear branch chains detected");
|
|
92
134
|
return;
|
|
93
135
|
}
|
|
94
136
|
|
|
95
137
|
for (const chain of chains) {
|
|
96
138
|
const name = chain[0];
|
|
97
139
|
if (name === undefined) continue;
|
|
140
|
+
const currentData = yield* stacks.load();
|
|
141
|
+
if (currentData.stacks[name] !== undefined) {
|
|
142
|
+
yield* warn(`Stack "${name}" already exists, skipping: ${chain.join(" → ")}`);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
98
145
|
if (dryRun) {
|
|
99
|
-
yield* Console.
|
|
146
|
+
yield* Console.error(`Would create stack "${name}": ${chain.join(" → ")}`);
|
|
100
147
|
} else {
|
|
101
148
|
yield* stacks.createStack(name, chain);
|
|
102
|
-
yield*
|
|
149
|
+
yield* success(`Created stack "${name}": ${chain.join(" → ")}`);
|
|
103
150
|
}
|
|
104
151
|
}
|
|
105
152
|
|
|
106
153
|
if (dryRun) {
|
|
107
|
-
yield* Console.
|
|
154
|
+
yield* Console.error(
|
|
108
155
|
`\n${chains.length} stack${chains.length === 1 ? "" : "s"} would be created`,
|
|
109
156
|
);
|
|
110
157
|
}
|
|
111
158
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
});
|
|
117
|
-
if (forkPoints.length > 0) {
|
|
118
|
-
yield* Console.log("\nNote: forked branches detected (not supported yet):");
|
|
119
|
-
for (const branch of forkPoints) {
|
|
120
|
-
const children = untracked.filter((c) => childOf.get(c) === branch);
|
|
121
|
-
yield* Console.log(` ${branch} → ${children.join(", ")}`);
|
|
159
|
+
if (forks.length > 0) {
|
|
160
|
+
yield* warn("Forked branches detected (not supported yet):");
|
|
161
|
+
for (const fork of forks) {
|
|
162
|
+
yield* Console.error(` ${fork.branch} → ${fork.children.join(", ")}`);
|
|
122
163
|
}
|
|
123
164
|
}
|
|
124
165
|
}),
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { Command, Flag } from "effect/unstable/cli";
|
|
2
|
+
import { Console, Effect } from "effect";
|
|
3
|
+
import { GitService } from "../services/Git.js";
|
|
4
|
+
import { StackService } from "../services/Stack.js";
|
|
5
|
+
import { success, warn } from "../ui.js";
|
|
6
|
+
|
|
7
|
+
const fixFlag = Flag.boolean("fix").pipe(Flag.withDescription("Auto-fix issues where possible"));
|
|
8
|
+
const jsonFlag = Flag.boolean("json").pipe(Flag.withDescription("Output as JSON"));
|
|
9
|
+
|
|
10
|
+
interface Finding {
|
|
11
|
+
type: "stale_branch" | "missing_trunk" | "duplicate_branch" | "parse_error";
|
|
12
|
+
message: string;
|
|
13
|
+
fixed: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const doctor = Command.make("doctor", { fix: fixFlag, json: jsonFlag }).pipe(
|
|
17
|
+
Command.withDescription("Check stack metadata for issues and optionally fix them"),
|
|
18
|
+
Command.withExamples([
|
|
19
|
+
{ command: "stacked doctor", description: "Check for metadata drift" },
|
|
20
|
+
{ command: "stacked doctor --fix", description: "Auto-fix detected issues" },
|
|
21
|
+
]),
|
|
22
|
+
Command.withHandler(({ fix, json }) =>
|
|
23
|
+
Effect.gen(function* () {
|
|
24
|
+
const git = yield* GitService;
|
|
25
|
+
const stacks = yield* StackService;
|
|
26
|
+
|
|
27
|
+
const data = yield* stacks.load();
|
|
28
|
+
const findings: Finding[] = [];
|
|
29
|
+
|
|
30
|
+
// Check 1: trunk branch exists
|
|
31
|
+
const trunkExists = yield* git
|
|
32
|
+
.branchExists(data.trunk)
|
|
33
|
+
.pipe(Effect.catchTag("GitError", () => Effect.succeed(false)));
|
|
34
|
+
if (!trunkExists) {
|
|
35
|
+
if (fix) {
|
|
36
|
+
// Auto-detect a trunk
|
|
37
|
+
for (const candidate of ["main", "master", "develop"]) {
|
|
38
|
+
const exists = yield* git
|
|
39
|
+
.branchExists(candidate)
|
|
40
|
+
.pipe(Effect.catchTag("GitError", () => Effect.succeed(false)));
|
|
41
|
+
if (exists) {
|
|
42
|
+
yield* stacks.setTrunk(candidate);
|
|
43
|
+
findings.push({
|
|
44
|
+
type: "missing_trunk",
|
|
45
|
+
message: `Trunk "${data.trunk}" not found, set to "${candidate}"`,
|
|
46
|
+
fixed: true,
|
|
47
|
+
});
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
} else {
|
|
52
|
+
findings.push({
|
|
53
|
+
type: "missing_trunk",
|
|
54
|
+
message: `Trunk branch "${data.trunk}" does not exist`,
|
|
55
|
+
fixed: false,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Check 2: all tracked branches exist in git
|
|
61
|
+
for (const [stackName, stack] of Object.entries(data.stacks)) {
|
|
62
|
+
for (const branch of stack.branches) {
|
|
63
|
+
const exists = yield* git
|
|
64
|
+
.branchExists(branch)
|
|
65
|
+
.pipe(Effect.catchTag("GitError", () => Effect.succeed(false)));
|
|
66
|
+
if (!exists) {
|
|
67
|
+
if (fix) {
|
|
68
|
+
yield* stacks.removeBranch(stackName, branch);
|
|
69
|
+
findings.push({
|
|
70
|
+
type: "stale_branch",
|
|
71
|
+
message: `Removed stale branch "${branch}" from stack "${stackName}"`,
|
|
72
|
+
fixed: true,
|
|
73
|
+
});
|
|
74
|
+
} else {
|
|
75
|
+
findings.push({
|
|
76
|
+
type: "stale_branch",
|
|
77
|
+
message: `Branch "${branch}" in stack "${stackName}" does not exist in git`,
|
|
78
|
+
fixed: false,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Check 3: no branches in multiple stacks
|
|
86
|
+
const branchToStacks = new Map<string, string[]>();
|
|
87
|
+
for (const [stackName, stack] of Object.entries(data.stacks)) {
|
|
88
|
+
for (const branch of stack.branches) {
|
|
89
|
+
const existing = branchToStacks.get(branch) ?? [];
|
|
90
|
+
existing.push(stackName);
|
|
91
|
+
branchToStacks.set(branch, existing);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
for (const [branch, stackNames] of branchToStacks) {
|
|
95
|
+
if (stackNames.length > 1) {
|
|
96
|
+
findings.push({
|
|
97
|
+
type: "duplicate_branch",
|
|
98
|
+
message: `Branch "${branch}" appears in multiple stacks: ${stackNames.join(", ")}`,
|
|
99
|
+
fixed: false,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (json) {
|
|
105
|
+
// @effect-diagnostics-next-line effect/preferSchemaOverJson:off
|
|
106
|
+
yield* Console.log(JSON.stringify({ findings }, null, 2));
|
|
107
|
+
} else if (findings.length === 0) {
|
|
108
|
+
yield* success("No issues found");
|
|
109
|
+
} else {
|
|
110
|
+
for (const f of findings) {
|
|
111
|
+
if (f.fixed) {
|
|
112
|
+
yield* success(f.message);
|
|
113
|
+
} else {
|
|
114
|
+
yield* warn(f.message);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const fixable = findings.filter((f) => !f.fixed).length;
|
|
118
|
+
if (fixable > 0 && !fix) {
|
|
119
|
+
yield* Console.error(`\nRun 'stacked doctor --fix' to auto-fix ${fixable} issue(s)`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}),
|
|
123
|
+
),
|
|
124
|
+
);
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Command, Flag } from "effect/unstable/cli";
|
|
2
|
+
import { Console, Effect } from "effect";
|
|
3
|
+
import { GitService } from "../services/Git.js";
|
|
4
|
+
import { StackService } from "../services/Stack.js";
|
|
5
|
+
import { ErrorCode, StackError } from "../errors/index.js";
|
|
6
|
+
import { success } from "../ui.js";
|
|
7
|
+
|
|
8
|
+
const jsonFlag = Flag.boolean("json").pipe(Flag.withDescription("Output as JSON"));
|
|
9
|
+
|
|
10
|
+
export const down = Command.make("down", { json: jsonFlag }).pipe(
|
|
11
|
+
Command.withDescription("Move down one branch in the stack"),
|
|
12
|
+
Command.withExamples([{ command: "stacked down", description: "Move to the next branch below" }]),
|
|
13
|
+
Command.withHandler(({ json }) =>
|
|
14
|
+
Effect.gen(function* () {
|
|
15
|
+
const git = yield* GitService;
|
|
16
|
+
const stacks = yield* StackService;
|
|
17
|
+
|
|
18
|
+
const currentBranch = yield* git.currentBranch();
|
|
19
|
+
const result = yield* stacks.currentStack();
|
|
20
|
+
if (result === null) {
|
|
21
|
+
return yield* new StackError({
|
|
22
|
+
code: ErrorCode.NOT_IN_STACK,
|
|
23
|
+
message:
|
|
24
|
+
"Not on a stacked branch. Run 'stacked list' to see your stacks, or 'stacked create <name>' to start one.",
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const { branches } = result.stack;
|
|
29
|
+
const idx = branches.indexOf(currentBranch);
|
|
30
|
+
if (idx === -1) {
|
|
31
|
+
return yield* new StackError({
|
|
32
|
+
code: ErrorCode.NOT_IN_STACK,
|
|
33
|
+
message: "Current branch not found in stack",
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (idx === 0) {
|
|
38
|
+
return yield* new StackError({
|
|
39
|
+
code: ErrorCode.ALREADY_AT_BOTTOM,
|
|
40
|
+
message: "Already at the bottom of the stack",
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const prev = branches[idx - 1];
|
|
45
|
+
if (prev === undefined) {
|
|
46
|
+
return yield* new StackError({
|
|
47
|
+
code: ErrorCode.ALREADY_AT_BOTTOM,
|
|
48
|
+
message: "Already at the bottom of the stack",
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
yield* git.checkout(prev);
|
|
53
|
+
|
|
54
|
+
if (json) {
|
|
55
|
+
// @effect-diagnostics-next-line effect/preferSchemaOverJson:off
|
|
56
|
+
yield* Console.log(JSON.stringify({ branch: prev, from: currentBranch }, null, 2));
|
|
57
|
+
} else {
|
|
58
|
+
yield* success(`Switched to ${prev}`);
|
|
59
|
+
}
|
|
60
|
+
}),
|
|
61
|
+
),
|
|
62
|
+
);
|
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { ErrorCode, StackError } from "../../errors/index.js";
|
|
3
|
+
|
|
4
|
+
const BRANCH_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._\-/]*$/;
|
|
5
|
+
|
|
6
|
+
export const validateBranchName = Effect.fn("validateBranchName")(function* (name: string) {
|
|
7
|
+
if (name === "") {
|
|
8
|
+
return yield* new StackError({
|
|
9
|
+
code: ErrorCode.INVALID_BRANCH_NAME,
|
|
10
|
+
message: "Branch name cannot be empty",
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
if (name.startsWith("-")) {
|
|
14
|
+
return yield* new StackError({
|
|
15
|
+
code: ErrorCode.INVALID_BRANCH_NAME,
|
|
16
|
+
message: `Invalid branch name "${name}": cannot start with "-"`,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
if (name.includes("..")) {
|
|
20
|
+
return yield* new StackError({
|
|
21
|
+
code: ErrorCode.INVALID_BRANCH_NAME,
|
|
22
|
+
message: `Invalid branch name "${name}": cannot contain ".."`,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
if (name.includes(" ")) {
|
|
26
|
+
return yield* new StackError({
|
|
27
|
+
code: ErrorCode.INVALID_BRANCH_NAME,
|
|
28
|
+
message: `Invalid branch name "${name}": cannot contain spaces`,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
if (name.endsWith(".lock")) {
|
|
32
|
+
return yield* new StackError({
|
|
33
|
+
code: ErrorCode.INVALID_BRANCH_NAME,
|
|
34
|
+
message: `Invalid branch name "${name}": cannot end with ".lock"`,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
if (name.endsWith(".")) {
|
|
38
|
+
return yield* new StackError({
|
|
39
|
+
code: ErrorCode.INVALID_BRANCH_NAME,
|
|
40
|
+
message: `Invalid branch name "${name}": cannot end with "."`,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
if (name.endsWith("/")) {
|
|
44
|
+
return yield* new StackError({
|
|
45
|
+
code: ErrorCode.INVALID_BRANCH_NAME,
|
|
46
|
+
message: `Invalid branch name "${name}": cannot end with "/"`,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
if (name === "@") {
|
|
50
|
+
return yield* new StackError({
|
|
51
|
+
code: ErrorCode.INVALID_BRANCH_NAME,
|
|
52
|
+
message: `Invalid branch name "${name}": "@" alone is not a valid branch name`,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
if (!BRANCH_NAME_PATTERN.test(name)) {
|
|
56
|
+
return yield* new StackError({
|
|
57
|
+
code: ErrorCode.INVALID_BRANCH_NAME,
|
|
58
|
+
message: `Invalid branch name "${name}": must start with alphanumeric and contain only alphanumerics, dots, hyphens, underscores, or slashes`,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
});
|