@cvr/stacked 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +78 -22
- package/bin/stacked +0 -0
- package/package.json +3 -2
- package/scripts/build.ts +8 -1
- package/skills/stacked/SKILL.md +221 -39
- package/src/commands/adopt.ts +70 -8
- package/src/commands/amend.ts +107 -0
- package/src/commands/bottom.ts +26 -8
- package/src/commands/checkout.ts +25 -6
- package/src/commands/clean.ts +106 -27
- package/src/commands/create.ts +74 -12
- package/src/commands/delete.ts +92 -24
- package/src/commands/detect.ts +152 -0
- package/src/commands/doctor.ts +124 -0
- package/src/commands/down.ts +62 -0
- package/src/commands/helpers/validate.ts +61 -0
- package/src/commands/index.ts +29 -1
- package/src/commands/init.ts +40 -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 +93 -0
- package/src/commands/split.ts +108 -0
- package/src/commands/stacks.ts +33 -7
- package/src/commands/status.ts +55 -0
- package/src/commands/submit.ts +245 -16
- package/src/commands/sync.ts +127 -19
- 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 +30 -0
- package/src/global.d.ts +2 -0
- package/src/main.ts +70 -3
- package/src/services/Git.ts +102 -30
- package/src/services/GitHub.ts +56 -18
- package/src/services/Stack.ts +65 -58
- package/src/ui.ts +173 -0
package/src/commands/adopt.ts
CHANGED
|
@@ -2,26 +2,80 @@ import { Argument, Command, Flag } from "effect/unstable/cli";
|
|
|
2
2
|
import { Console, Effect, Option } 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 { validateBranchName } from "./helpers/validate.js";
|
|
7
|
+
import { dim } from "../ui.js";
|
|
6
8
|
|
|
7
|
-
const branchArg = Argument.string("branch");
|
|
8
|
-
const afterFlag = Flag.string("after").pipe(
|
|
9
|
+
const branchArg = Argument.string("branch").pipe(Argument.withDescription("Branch name to adopt"));
|
|
10
|
+
const afterFlag = Flag.string("after").pipe(
|
|
11
|
+
Flag.optional,
|
|
12
|
+
Flag.withAlias("a"),
|
|
13
|
+
Flag.withDescription("Insert after this branch in the stack"),
|
|
14
|
+
);
|
|
15
|
+
const jsonFlag = Flag.boolean("json").pipe(Flag.withDescription("Output as JSON"));
|
|
9
16
|
|
|
10
|
-
export const adopt = Command.make("adopt", {
|
|
17
|
+
export const adopt = Command.make("adopt", {
|
|
18
|
+
branch: branchArg,
|
|
19
|
+
after: afterFlag,
|
|
20
|
+
json: jsonFlag,
|
|
21
|
+
}).pipe(
|
|
11
22
|
Command.withDescription("Adopt existing git branch into current stack"),
|
|
12
|
-
Command.
|
|
23
|
+
Command.withExamples([
|
|
24
|
+
{ command: "stacked adopt feat-existing", description: "Add branch to current stack" },
|
|
25
|
+
{
|
|
26
|
+
command: "stacked adopt feat-x --after feat-a",
|
|
27
|
+
description: "Insert after a specific branch",
|
|
28
|
+
},
|
|
29
|
+
]),
|
|
30
|
+
Command.withHandler(({ branch, after, json }) =>
|
|
13
31
|
Effect.gen(function* () {
|
|
14
32
|
const git = yield* GitService;
|
|
15
33
|
const stacks = yield* StackService;
|
|
16
34
|
|
|
35
|
+
yield* validateBranchName(branch);
|
|
36
|
+
|
|
37
|
+
const trunk = yield* stacks.getTrunk();
|
|
38
|
+
if (branch === trunk) {
|
|
39
|
+
return yield* new StackError({
|
|
40
|
+
code: ErrorCode.TRUNK_ERROR,
|
|
41
|
+
message: `Cannot adopt trunk branch "${trunk}" into a stack`,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
17
45
|
const exists = yield* git.branchExists(branch);
|
|
18
46
|
if (!exists) {
|
|
19
|
-
return yield* new StackError({
|
|
47
|
+
return yield* new StackError({
|
|
48
|
+
code: ErrorCode.BRANCH_NOT_FOUND,
|
|
49
|
+
message: `Branch "${branch}" does not exist`,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const alreadyTracked = yield* stacks.findBranchStack(branch);
|
|
54
|
+
if (alreadyTracked !== null) {
|
|
55
|
+
const result = yield* stacks.currentStack();
|
|
56
|
+
if (result !== null && alreadyTracked.name === result.name) {
|
|
57
|
+
if (json) {
|
|
58
|
+
// @effect-diagnostics-next-line effect/preferSchemaOverJson:off
|
|
59
|
+
yield* Console.log(
|
|
60
|
+
JSON.stringify(
|
|
61
|
+
{ adopted: branch, stack: result.name, alreadyTracked: true },
|
|
62
|
+
null,
|
|
63
|
+
2,
|
|
64
|
+
),
|
|
65
|
+
);
|
|
66
|
+
} else {
|
|
67
|
+
yield* Console.error(`Branch "${branch}" is already in stack "${result.name}"`);
|
|
68
|
+
}
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
return yield* new StackError({
|
|
72
|
+
code: ErrorCode.BRANCH_EXISTS,
|
|
73
|
+
message: `Branch "${branch}" is already tracked in stack "${alreadyTracked.name}"`,
|
|
74
|
+
});
|
|
20
75
|
}
|
|
21
76
|
|
|
22
77
|
const result = yield* stacks.currentStack();
|
|
23
78
|
if (result === null) {
|
|
24
|
-
const trunk = yield* stacks.getTrunk();
|
|
25
79
|
const currentBranch = yield* git.currentBranch();
|
|
26
80
|
if (currentBranch === trunk) {
|
|
27
81
|
yield* stacks.createStack(branch, [branch]);
|
|
@@ -33,7 +87,15 @@ export const adopt = Command.make("adopt", { branch: branchArg, after: afterFlag
|
|
|
33
87
|
yield* stacks.addBranch(result.name, branch, afterBranch);
|
|
34
88
|
}
|
|
35
89
|
|
|
36
|
-
|
|
90
|
+
if (json) {
|
|
91
|
+
const stackResult = yield* stacks.currentStack();
|
|
92
|
+
const stackName = stackResult?.name ?? branch;
|
|
93
|
+
// @effect-diagnostics-next-line effect/preferSchemaOverJson:off
|
|
94
|
+
yield* Console.log(JSON.stringify({ adopted: branch, stack: stackName }, null, 2));
|
|
95
|
+
} else {
|
|
96
|
+
yield* Console.error(`Adopted ${branch} into stack`);
|
|
97
|
+
yield* Console.error(dim("Run 'stacked sync' to rebase onto the new parent."));
|
|
98
|
+
}
|
|
37
99
|
}),
|
|
38
100
|
),
|
|
39
101
|
);
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { Command, Flag } from "effect/unstable/cli";
|
|
2
|
+
import { Console, Effect, Option } 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, withSpinner } from "../ui.js";
|
|
7
|
+
|
|
8
|
+
const editFlag = Flag.boolean("edit").pipe(Flag.withDescription("Open editor for commit message"));
|
|
9
|
+
const jsonFlag = Flag.boolean("json").pipe(Flag.withDescription("Output as JSON"));
|
|
10
|
+
const fromFlag = Flag.string("from").pipe(
|
|
11
|
+
Flag.optional,
|
|
12
|
+
Flag.withDescription("Start syncing from this branch (defaults to current)"),
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
export const amend = Command.make("amend", {
|
|
16
|
+
edit: editFlag,
|
|
17
|
+
json: jsonFlag,
|
|
18
|
+
from: fromFlag,
|
|
19
|
+
}).pipe(
|
|
20
|
+
Command.withDescription("Amend current commit and rebase children"),
|
|
21
|
+
Command.withExamples([
|
|
22
|
+
{ command: "stacked amend", description: "Amend and auto-rebase children" },
|
|
23
|
+
{ command: "stacked amend --edit", description: "Amend with editor" },
|
|
24
|
+
]),
|
|
25
|
+
Command.withHandler(({ edit, json, from }) =>
|
|
26
|
+
Effect.gen(function* () {
|
|
27
|
+
const git = yield* GitService;
|
|
28
|
+
const stacks = yield* StackService;
|
|
29
|
+
|
|
30
|
+
const currentBranch = yield* git.currentBranch();
|
|
31
|
+
const result = yield* stacks.currentStack();
|
|
32
|
+
if (result === null) {
|
|
33
|
+
return yield* new StackError({
|
|
34
|
+
code: ErrorCode.NOT_IN_STACK,
|
|
35
|
+
message:
|
|
36
|
+
"Not on a stacked branch. Run 'stacked list' to see your stacks, or 'stacked create <name>' to start one.",
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
yield* git.commitAmend({ edit });
|
|
41
|
+
|
|
42
|
+
const fromBranch = Option.isSome(from) ? from.value : currentBranch;
|
|
43
|
+
|
|
44
|
+
// Find children to rebase
|
|
45
|
+
const { branches } = result.stack;
|
|
46
|
+
const idx = branches.indexOf(fromBranch);
|
|
47
|
+
if (idx === -1 || idx >= branches.length - 1) {
|
|
48
|
+
if (json) {
|
|
49
|
+
// @effect-diagnostics-next-line effect/preferSchemaOverJson:off
|
|
50
|
+
yield* Console.log(JSON.stringify({ amended: currentBranch, synced: [] }, null, 2));
|
|
51
|
+
} else {
|
|
52
|
+
yield* success(`Amended ${currentBranch} (no children to rebase)`);
|
|
53
|
+
}
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Rebase children
|
|
58
|
+
const children = branches.slice(idx + 1);
|
|
59
|
+
const synced: string[] = [];
|
|
60
|
+
|
|
61
|
+
yield* Effect.gen(function* () {
|
|
62
|
+
for (let i = 0; i < children.length; i++) {
|
|
63
|
+
const branch = children[i];
|
|
64
|
+
if (branch === undefined) continue;
|
|
65
|
+
const newBase = i === 0 ? fromBranch : (children[i - 1] ?? fromBranch);
|
|
66
|
+
|
|
67
|
+
const oldBase = yield* git
|
|
68
|
+
.mergeBase(branch, newBase)
|
|
69
|
+
.pipe(Effect.catchTag("GitError", () => Effect.succeed(newBase)));
|
|
70
|
+
|
|
71
|
+
yield* git.checkout(branch);
|
|
72
|
+
yield* withSpinner(
|
|
73
|
+
`Rebasing ${branch} onto ${newBase}`,
|
|
74
|
+
git.rebaseOnto(branch, newBase, oldBase),
|
|
75
|
+
).pipe(
|
|
76
|
+
Effect.catchTag("GitError", (e) =>
|
|
77
|
+
Effect.fail(
|
|
78
|
+
new StackError({
|
|
79
|
+
code: ErrorCode.REBASE_CONFLICT,
|
|
80
|
+
message: `Rebase conflict on ${branch}: ${e.message}\n\nResolve conflicts, then run:\n git rebase --continue`,
|
|
81
|
+
}),
|
|
82
|
+
),
|
|
83
|
+
),
|
|
84
|
+
);
|
|
85
|
+
synced.push(branch);
|
|
86
|
+
}
|
|
87
|
+
}).pipe(
|
|
88
|
+
Effect.ensuring(
|
|
89
|
+
git
|
|
90
|
+
.isRebaseInProgress()
|
|
91
|
+
.pipe(
|
|
92
|
+
Effect.andThen((inProgress) =>
|
|
93
|
+
inProgress ? Effect.void : git.checkout(currentBranch).pipe(Effect.ignore),
|
|
94
|
+
),
|
|
95
|
+
),
|
|
96
|
+
),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
if (json) {
|
|
100
|
+
// @effect-diagnostics-next-line effect/preferSchemaOverJson:off
|
|
101
|
+
yield* Console.log(JSON.stringify({ amended: currentBranch, synced }, null, 2));
|
|
102
|
+
} else {
|
|
103
|
+
yield* success(`Amended ${currentBranch} and rebased ${synced.length} child branch(es)`);
|
|
104
|
+
}
|
|
105
|
+
}),
|
|
106
|
+
),
|
|
107
|
+
);
|
package/src/commands/bottom.ts
CHANGED
|
@@ -1,29 +1,47 @@
|
|
|
1
|
-
import { Command } from "effect/unstable/cli";
|
|
1
|
+
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 { ErrorCode, StackError } from "../errors/index.js";
|
|
6
|
+
import { success } from "../ui.js";
|
|
5
7
|
|
|
6
|
-
|
|
8
|
+
const jsonFlag = Flag.boolean("json").pipe(Flag.withDescription("Output as JSON"));
|
|
9
|
+
|
|
10
|
+
export const bottom = Command.make("bottom", { json: jsonFlag }).pipe(
|
|
7
11
|
Command.withDescription("Checkout bottom branch of stack"),
|
|
8
|
-
Command.
|
|
12
|
+
Command.withExamples([
|
|
13
|
+
{ command: "stacked bottom", description: "Jump to the bottom of the stack" },
|
|
14
|
+
]),
|
|
15
|
+
Command.withHandler(({ json }) =>
|
|
9
16
|
Effect.gen(function* () {
|
|
10
17
|
const git = yield* GitService;
|
|
11
18
|
const stacks = yield* StackService;
|
|
12
19
|
|
|
13
20
|
const result = yield* stacks.currentStack();
|
|
14
21
|
if (result === null) {
|
|
15
|
-
yield*
|
|
16
|
-
|
|
22
|
+
return yield* new StackError({
|
|
23
|
+
code: ErrorCode.NOT_IN_STACK,
|
|
24
|
+
message:
|
|
25
|
+
"Not on a stacked branch. Run 'stacked list' to see your stacks, or 'stacked create <name>' to start one.",
|
|
26
|
+
});
|
|
17
27
|
}
|
|
18
28
|
|
|
19
29
|
const bottomBranch = result.stack.branches[0];
|
|
20
30
|
if (bottomBranch === undefined) {
|
|
21
|
-
yield*
|
|
22
|
-
|
|
31
|
+
return yield* new StackError({
|
|
32
|
+
code: ErrorCode.STACK_EMPTY,
|
|
33
|
+
message: "Stack is empty. Run 'stacked create <name>' to add a branch.",
|
|
34
|
+
});
|
|
23
35
|
}
|
|
24
36
|
|
|
25
37
|
yield* git.checkout(bottomBranch);
|
|
26
|
-
|
|
38
|
+
|
|
39
|
+
if (json) {
|
|
40
|
+
// @effect-diagnostics-next-line effect/preferSchemaOverJson:off
|
|
41
|
+
yield* Console.log(JSON.stringify({ branch: bottomBranch }, null, 2));
|
|
42
|
+
} else {
|
|
43
|
+
yield* success(`Switched to ${bottomBranch}`);
|
|
44
|
+
}
|
|
27
45
|
}),
|
|
28
46
|
),
|
|
29
47
|
);
|
package/src/commands/checkout.ts
CHANGED
|
@@ -1,15 +1,34 @@
|
|
|
1
|
-
import { Argument, Command } from "effect/unstable/cli";
|
|
2
|
-
import { Effect } from "effect";
|
|
1
|
+
import { Argument, Command, Flag } from "effect/unstable/cli";
|
|
2
|
+
import { Console, Effect } from "effect";
|
|
3
3
|
import { GitService } from "../services/Git.js";
|
|
4
|
+
import { StackService } from "../services/Stack.js";
|
|
5
|
+
import { success } from "../ui.js";
|
|
4
6
|
|
|
5
|
-
const nameArg = Argument.string("name");
|
|
7
|
+
const nameArg = Argument.string("name").pipe(Argument.withDescription("Branch name to check out"));
|
|
8
|
+
const jsonFlag = Flag.boolean("json").pipe(Flag.withDescription("Output as JSON"));
|
|
6
9
|
|
|
7
|
-
export const checkout = Command.make("checkout", { name: nameArg }).pipe(
|
|
8
|
-
Command.withDescription("Switch to branch in
|
|
9
|
-
Command.
|
|
10
|
+
export const checkout = Command.make("checkout", { name: nameArg, json: jsonFlag }).pipe(
|
|
11
|
+
Command.withDescription("Switch to a branch (falls through to git if not in a stack)"),
|
|
12
|
+
Command.withExamples([
|
|
13
|
+
{ command: "stacked checkout feat-b", description: "Switch to a stacked branch" },
|
|
14
|
+
]),
|
|
15
|
+
Command.withHandler(({ name, json }) =>
|
|
10
16
|
Effect.gen(function* () {
|
|
11
17
|
const git = yield* GitService;
|
|
18
|
+
const stacks = yield* StackService;
|
|
19
|
+
|
|
20
|
+
const result = yield* stacks.findBranchStack(name);
|
|
21
|
+
|
|
12
22
|
yield* git.checkout(name);
|
|
23
|
+
|
|
24
|
+
if (json) {
|
|
25
|
+
// @effect-diagnostics-next-line effect/preferSchemaOverJson:off
|
|
26
|
+
yield* Console.log(JSON.stringify({ branch: name, inStack: result !== null }, null, 2));
|
|
27
|
+
} else if (result !== null) {
|
|
28
|
+
yield* success(`Switched to ${name}`);
|
|
29
|
+
} else {
|
|
30
|
+
yield* success(`Switched to ${name} (not in a stack)`);
|
|
31
|
+
}
|
|
13
32
|
}),
|
|
14
33
|
),
|
|
15
34
|
);
|
package/src/commands/clean.ts
CHANGED
|
@@ -3,27 +3,60 @@ import { Console, Effect } from "effect";
|
|
|
3
3
|
import { GitService } from "../services/Git.js";
|
|
4
4
|
import { GitHubService } from "../services/GitHub.js";
|
|
5
5
|
import { StackService } from "../services/Stack.js";
|
|
6
|
+
import { ErrorCode, StackError } from "../errors/index.js";
|
|
7
|
+
import { success, warn, dim, confirm } from "../ui.js";
|
|
6
8
|
|
|
7
|
-
const dryRunFlag = Flag.boolean("dry-run")
|
|
9
|
+
const dryRunFlag = Flag.boolean("dry-run").pipe(
|
|
10
|
+
Flag.withDescription("Show what would be removed without making changes"),
|
|
11
|
+
);
|
|
12
|
+
const jsonFlag = Flag.boolean("json").pipe(Flag.withDescription("Output as JSON"));
|
|
8
13
|
|
|
9
|
-
export const clean = Command.make("clean", { dryRun: dryRunFlag }).pipe(
|
|
14
|
+
export const clean = Command.make("clean", { dryRun: dryRunFlag, json: jsonFlag }).pipe(
|
|
10
15
|
Command.withDescription("Remove merged branches from stacks (bottom-up)"),
|
|
11
|
-
Command.
|
|
16
|
+
Command.withExamples([
|
|
17
|
+
{ command: "stacked clean", description: "Remove merged branches" },
|
|
18
|
+
{ command: "stacked clean --dry-run", description: "Preview what would be removed" },
|
|
19
|
+
]),
|
|
20
|
+
Command.withHandler(({ dryRun, json }) =>
|
|
12
21
|
Effect.gen(function* () {
|
|
13
22
|
const git = yield* GitService;
|
|
14
23
|
const gh = yield* GitHubService;
|
|
15
24
|
const stacks = yield* StackService;
|
|
16
25
|
|
|
17
|
-
const
|
|
26
|
+
const ghInstalled = yield* gh.isGhInstalled();
|
|
27
|
+
if (!ghInstalled) {
|
|
28
|
+
return yield* new StackError({
|
|
29
|
+
code: ErrorCode.GH_NOT_INSTALLED,
|
|
30
|
+
message: "gh CLI is not installed. Install it from https://cli.github.com",
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let currentBranch = yield* git.currentBranch();
|
|
18
35
|
const data = yield* stacks.load();
|
|
19
36
|
|
|
37
|
+
// Fetch all PR statuses in parallel across all stacks
|
|
38
|
+
const allBranches = Object.entries(data.stacks).flatMap(([stackName, stack]) =>
|
|
39
|
+
stack.branches.map((branch) => ({ stackName, branch })),
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const prResults = yield* Effect.forEach(
|
|
43
|
+
allBranches,
|
|
44
|
+
({ branch }) =>
|
|
45
|
+
gh.getPR(branch).pipe(
|
|
46
|
+
Effect.catchTag("GitHubError", () => Effect.succeed(null)),
|
|
47
|
+
Effect.map((pr) => [branch, pr] as const),
|
|
48
|
+
),
|
|
49
|
+
{ concurrency: 5 },
|
|
50
|
+
);
|
|
51
|
+
const prMap = new Map(prResults);
|
|
52
|
+
|
|
20
53
|
const toRemove: Array<{ stackName: string; branch: string }> = [];
|
|
21
54
|
const skippedMerged: Array<{ stackName: string; branch: string }> = [];
|
|
22
55
|
|
|
23
56
|
for (const [stackName, stack] of Object.entries(data.stacks)) {
|
|
24
57
|
let hitNonMerged = false;
|
|
25
58
|
for (const branch of stack.branches) {
|
|
26
|
-
const pr =
|
|
59
|
+
const pr = prMap.get(branch) ?? null;
|
|
27
60
|
const isMerged = pr !== null && pr.state === "MERGED";
|
|
28
61
|
|
|
29
62
|
if (!hitNonMerged && isMerged) {
|
|
@@ -36,48 +69,94 @@ export const clean = Command.make("clean", { dryRun: dryRunFlag }).pipe(
|
|
|
36
69
|
}
|
|
37
70
|
|
|
38
71
|
if (toRemove.length === 0) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
yield* Console.log(
|
|
42
|
-
|
|
43
|
-
);
|
|
44
|
-
|
|
45
|
-
yield*
|
|
72
|
+
if (json) {
|
|
73
|
+
// @effect-diagnostics-next-line effect/preferSchemaOverJson:off
|
|
74
|
+
yield* Console.log(JSON.stringify({ removed: [], skipped: [] }, null, 2));
|
|
75
|
+
} else {
|
|
76
|
+
yield* Console.error("Nothing to clean");
|
|
77
|
+
if (skippedMerged.length > 0) {
|
|
78
|
+
yield* warn(
|
|
79
|
+
`${skippedMerged.length} merged branch${skippedMerged.length === 1 ? "" : "es"} skipped (non-merged branches below):`,
|
|
80
|
+
);
|
|
81
|
+
for (const { branch, stackName } of skippedMerged) {
|
|
82
|
+
yield* Console.error(dim(` ${branch} (${stackName})`));
|
|
83
|
+
}
|
|
46
84
|
}
|
|
47
85
|
}
|
|
48
86
|
return;
|
|
49
87
|
}
|
|
50
88
|
|
|
89
|
+
if (!dryRun) {
|
|
90
|
+
for (const { branch } of toRemove) {
|
|
91
|
+
yield* Console.error(dim(` ${branch}`));
|
|
92
|
+
}
|
|
93
|
+
const confirmed = yield* confirm(
|
|
94
|
+
`Remove ${toRemove.length} merged branch${toRemove.length === 1 ? "" : "es"}?`,
|
|
95
|
+
);
|
|
96
|
+
if (!confirmed) {
|
|
97
|
+
yield* Console.error("Aborted");
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const removed: string[] = [];
|
|
103
|
+
|
|
51
104
|
for (const { stackName, branch } of toRemove) {
|
|
52
105
|
if (dryRun) {
|
|
53
|
-
yield* Console.
|
|
106
|
+
yield* Console.error(`Would remove ${branch} from ${stackName}`);
|
|
107
|
+
removed.push(branch);
|
|
54
108
|
} else {
|
|
55
109
|
if (currentBranch === branch) {
|
|
56
110
|
const trunk = yield* stacks.getTrunk();
|
|
57
111
|
yield* git.checkout(trunk);
|
|
112
|
+
currentBranch = trunk;
|
|
113
|
+
}
|
|
114
|
+
const deleted = yield* git.deleteBranch(branch, true).pipe(
|
|
115
|
+
Effect.as(true),
|
|
116
|
+
Effect.catchTag("GitError", (e) =>
|
|
117
|
+
Console.error(`Warning: failed to delete local branch ${branch}: ${e.message}`).pipe(
|
|
118
|
+
Effect.as(false),
|
|
119
|
+
),
|
|
120
|
+
),
|
|
121
|
+
);
|
|
122
|
+
yield* git
|
|
123
|
+
.deleteRemoteBranch(branch)
|
|
124
|
+
.pipe(
|
|
125
|
+
Effect.catchTag("GitError", (e) =>
|
|
126
|
+
Console.error(`Warning: failed to delete remote branch ${branch}: ${e.message}`),
|
|
127
|
+
),
|
|
128
|
+
);
|
|
129
|
+
if (deleted) {
|
|
130
|
+
yield* stacks.removeBranch(stackName, branch);
|
|
131
|
+
removed.push(branch);
|
|
132
|
+
yield* success(`Removed ${branch} from ${stackName}`);
|
|
58
133
|
}
|
|
59
|
-
yield* stacks.removeBranch(stackName, branch);
|
|
60
|
-
yield* git.deleteBranch(branch, true).pipe(Effect.catch(() => Effect.void));
|
|
61
|
-
yield* Console.log(`Removed ${branch} from ${stackName}`);
|
|
62
134
|
}
|
|
63
135
|
}
|
|
64
136
|
|
|
65
|
-
if (
|
|
66
|
-
|
|
137
|
+
if (json) {
|
|
138
|
+
const skipped = skippedMerged.map((x) => x.branch);
|
|
139
|
+
// @effect-diagnostics-next-line effect/preferSchemaOverJson:off
|
|
140
|
+
yield* Console.log(JSON.stringify({ removed, skipped }, null, 2));
|
|
141
|
+
} else if (dryRun) {
|
|
142
|
+
yield* Console.error(
|
|
67
143
|
`\n${toRemove.length} branch${toRemove.length === 1 ? "" : "es"} would be removed`,
|
|
68
144
|
);
|
|
69
145
|
} else {
|
|
70
|
-
yield*
|
|
71
|
-
|
|
146
|
+
yield* success(
|
|
147
|
+
`Cleaned ${removed.length} merged branch${removed.length === 1 ? "" : "es"}`,
|
|
72
148
|
);
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
if (skippedMerged.length > 0) {
|
|
76
|
-
yield* Console.log(
|
|
77
|
-
`\nNote: ${skippedMerged.length} merged branch${skippedMerged.length === 1 ? "" : "es"} skipped (non-merged branches below):`,
|
|
149
|
+
yield* Console.error(
|
|
150
|
+
dim("Run 'stacked sync' then 'stacked submit' to rebase and retarget PRs."),
|
|
78
151
|
);
|
|
79
|
-
|
|
80
|
-
|
|
152
|
+
|
|
153
|
+
if (skippedMerged.length > 0) {
|
|
154
|
+
yield* warn(
|
|
155
|
+
`${skippedMerged.length} merged branch${skippedMerged.length === 1 ? "" : "es"} skipped (non-merged branches below):`,
|
|
156
|
+
);
|
|
157
|
+
for (const { branch, stackName } of skippedMerged) {
|
|
158
|
+
yield* Console.error(dim(` ${branch} (${stackName})`));
|
|
159
|
+
}
|
|
81
160
|
}
|
|
82
161
|
}
|
|
83
162
|
}),
|
package/src/commands/create.ts
CHANGED
|
@@ -2,31 +2,87 @@ import { Argument, Command, Flag } from "effect/unstable/cli";
|
|
|
2
2
|
import { Console, Effect, Option } from "effect";
|
|
3
3
|
import { GitService } from "../services/Git.js";
|
|
4
4
|
import { StackService } from "../services/Stack.js";
|
|
5
|
+
import { ErrorCode, StackError } from "../errors/index.js";
|
|
6
|
+
import { validateBranchName } from "./helpers/validate.js";
|
|
5
7
|
|
|
6
|
-
const nameArg = Argument.string("name");
|
|
7
|
-
const fromFlag = Flag.string("from").pipe(
|
|
8
|
+
const nameArg = Argument.string("name").pipe(Argument.withDescription("Branch name to create"));
|
|
9
|
+
const fromFlag = Flag.string("from").pipe(
|
|
10
|
+
Flag.optional,
|
|
11
|
+
Flag.withAlias("f"),
|
|
12
|
+
Flag.withDescription("Branch from a specific branch instead of current"),
|
|
13
|
+
);
|
|
14
|
+
const jsonFlag = Flag.boolean("json").pipe(Flag.withDescription("Output as JSON"));
|
|
8
15
|
|
|
9
|
-
export const create = Command.make("create", {
|
|
16
|
+
export const create = Command.make("create", {
|
|
17
|
+
name: nameArg,
|
|
18
|
+
from: fromFlag,
|
|
19
|
+
json: jsonFlag,
|
|
20
|
+
}).pipe(
|
|
10
21
|
Command.withDescription("Create a new branch on top of current branch in stack"),
|
|
11
|
-
Command.
|
|
22
|
+
Command.withExamples([
|
|
23
|
+
{ command: "stacked create feat-auth", description: "Create branch on top of current" },
|
|
24
|
+
{
|
|
25
|
+
command: "stacked create feat-ui --from feat-auth",
|
|
26
|
+
description: "Branch from a specific branch",
|
|
27
|
+
},
|
|
28
|
+
]),
|
|
29
|
+
Command.withHandler(({ name, from, json }) =>
|
|
12
30
|
Effect.gen(function* () {
|
|
13
31
|
const git = yield* GitService;
|
|
14
32
|
const stacks = yield* StackService;
|
|
15
33
|
|
|
34
|
+
yield* validateBranchName(name);
|
|
35
|
+
|
|
16
36
|
const currentBranch = yield* git.currentBranch();
|
|
17
37
|
const baseBranch = Option.isSome(from) ? from.value : currentBranch;
|
|
18
38
|
const trunk = yield* stacks.getTrunk();
|
|
19
39
|
|
|
20
|
-
|
|
21
|
-
|
|
40
|
+
if (name === trunk) {
|
|
41
|
+
return yield* new StackError({
|
|
42
|
+
code: ErrorCode.TRUNK_ERROR,
|
|
43
|
+
message: `Cannot create a branch with the same name as trunk ("${trunk}")`,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (Option.isSome(from)) {
|
|
48
|
+
const fromExists = yield* git.branchExists(from.value);
|
|
49
|
+
if (!fromExists) {
|
|
50
|
+
return yield* new StackError({
|
|
51
|
+
code: ErrorCode.BRANCH_NOT_FOUND,
|
|
52
|
+
message: `Branch "${from.value}" does not exist`,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
22
56
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
57
|
+
const branchAlreadyExists = yield* git.branchExists(name);
|
|
58
|
+
if (branchAlreadyExists) {
|
|
59
|
+
const tracked = yield* stacks.findBranchStack(name);
|
|
60
|
+
if (tracked !== null) {
|
|
61
|
+
if (json) {
|
|
62
|
+
// @effect-diagnostics-next-line effect/preferSchemaOverJson:off
|
|
63
|
+
yield* Console.log(
|
|
64
|
+
JSON.stringify(
|
|
65
|
+
{ branch: name, stack: tracked.name, base: baseBranch, alreadyExists: true },
|
|
66
|
+
null,
|
|
67
|
+
2,
|
|
68
|
+
),
|
|
69
|
+
);
|
|
70
|
+
} else {
|
|
71
|
+
yield* Console.error(`Branch "${name}" already exists in stack "${tracked.name}"`);
|
|
72
|
+
}
|
|
73
|
+
return;
|
|
27
74
|
}
|
|
75
|
+
return yield* new StackError({
|
|
76
|
+
code: ErrorCode.BRANCH_EXISTS,
|
|
77
|
+
message: `Branch "${name}" already exists but is not tracked in any stack`,
|
|
78
|
+
});
|
|
28
79
|
}
|
|
29
80
|
|
|
81
|
+
const existing = yield* stacks.findBranchStack(baseBranch);
|
|
82
|
+
let stackName = existing?.name ?? null;
|
|
83
|
+
|
|
84
|
+
yield* git.createBranch(name, baseBranch);
|
|
85
|
+
|
|
30
86
|
if (stackName === null) {
|
|
31
87
|
if (baseBranch === trunk) {
|
|
32
88
|
stackName = name;
|
|
@@ -37,10 +93,16 @@ export const create = Command.make("create", { name: nameArg, from: fromFlag }).
|
|
|
37
93
|
}
|
|
38
94
|
}
|
|
39
95
|
|
|
40
|
-
yield* git.createBranch(name, baseBranch);
|
|
41
96
|
yield* stacks.addBranch(stackName, name, baseBranch === trunk ? undefined : baseBranch);
|
|
42
97
|
|
|
43
|
-
|
|
98
|
+
if (json) {
|
|
99
|
+
// @effect-diagnostics-next-line effect/preferSchemaOverJson:off
|
|
100
|
+
yield* Console.log(
|
|
101
|
+
JSON.stringify({ branch: name, stack: stackName, base: baseBranch }, null, 2),
|
|
102
|
+
);
|
|
103
|
+
} else {
|
|
104
|
+
yield* Console.error(`Created branch ${name} on top of ${baseBranch}`);
|
|
105
|
+
}
|
|
44
106
|
}),
|
|
45
107
|
),
|
|
46
108
|
);
|