@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/errors/index.ts
CHANGED
|
@@ -1,15 +1,45 @@
|
|
|
1
1
|
import { Schema } from "effect";
|
|
2
2
|
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// Error Codes
|
|
5
|
+
// ============================================================================
|
|
6
|
+
|
|
7
|
+
export const ErrorCode = {
|
|
8
|
+
BRANCH_EXISTS: "BRANCH_EXISTS",
|
|
9
|
+
BRANCH_NOT_FOUND: "BRANCH_NOT_FOUND",
|
|
10
|
+
NOT_IN_STACK: "NOT_IN_STACK",
|
|
11
|
+
DIRTY_WORKTREE: "DIRTY_WORKTREE",
|
|
12
|
+
REBASE_CONFLICT: "REBASE_CONFLICT",
|
|
13
|
+
GH_NOT_INSTALLED: "GH_NOT_INSTALLED",
|
|
14
|
+
STACK_NOT_FOUND: "STACK_NOT_FOUND",
|
|
15
|
+
INVALID_BRANCH_NAME: "INVALID_BRANCH_NAME",
|
|
16
|
+
NOT_A_GIT_REPO: "NOT_A_GIT_REPO",
|
|
17
|
+
ALREADY_AT_TOP: "ALREADY_AT_TOP",
|
|
18
|
+
ALREADY_AT_BOTTOM: "ALREADY_AT_BOTTOM",
|
|
19
|
+
STACK_EMPTY: "STACK_EMPTY",
|
|
20
|
+
TRUNK_ERROR: "TRUNK_ERROR",
|
|
21
|
+
STACK_EXISTS: "STACK_EXISTS",
|
|
22
|
+
} as const;
|
|
23
|
+
|
|
24
|
+
export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
|
|
25
|
+
|
|
26
|
+
// ============================================================================
|
|
27
|
+
// Error Classes
|
|
28
|
+
// ============================================================================
|
|
29
|
+
|
|
3
30
|
export class GitError extends Schema.TaggedErrorClass<GitError>()("GitError", {
|
|
4
31
|
message: Schema.String,
|
|
5
32
|
command: Schema.optional(Schema.String),
|
|
33
|
+
code: Schema.optional(Schema.String),
|
|
6
34
|
}) {}
|
|
7
35
|
|
|
8
36
|
export class StackError extends Schema.TaggedErrorClass<StackError>()("StackError", {
|
|
9
37
|
message: Schema.String,
|
|
38
|
+
code: Schema.optional(Schema.String),
|
|
10
39
|
}) {}
|
|
11
40
|
|
|
12
41
|
export class GitHubError extends Schema.TaggedErrorClass<GitHubError>()("GitHubError", {
|
|
13
42
|
message: Schema.String,
|
|
14
43
|
command: Schema.optional(Schema.String),
|
|
44
|
+
code: Schema.optional(Schema.String),
|
|
15
45
|
}) {}
|
package/src/global.d.ts
ADDED
package/src/main.ts
CHANGED
|
@@ -1,14 +1,41 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { Command } from "effect/unstable/cli";
|
|
3
3
|
import { BunRuntime, BunServices } from "@effect/platform-bun";
|
|
4
|
-
import { Effect, Layer } from "effect";
|
|
4
|
+
import { Console, Effect, Layer } from "effect";
|
|
5
5
|
import { command } from "./commands/index.js";
|
|
6
6
|
import { GitService } from "./services/Git.js";
|
|
7
7
|
import { StackService } from "./services/Stack.js";
|
|
8
8
|
import { GitHubService } from "./services/GitHub.js";
|
|
9
|
+
import { OutputConfig } from "./ui.js";
|
|
10
|
+
|
|
11
|
+
const version = typeof __VERSION__ !== "undefined" ? __VERSION__ : "dev";
|
|
12
|
+
|
|
13
|
+
// ============================================================================
|
|
14
|
+
// Global Flags (parsed before CLI framework, stripped from argv)
|
|
15
|
+
// ============================================================================
|
|
16
|
+
|
|
17
|
+
const globalFlags = new Set(["--verbose", "--quiet", "-q", "--no-color", "--yes", "-y"]);
|
|
18
|
+
const flagArgs = new Set(process.argv.filter((a) => globalFlags.has(a)));
|
|
19
|
+
process.argv = process.argv.filter((a) => !globalFlags.has(a));
|
|
20
|
+
|
|
21
|
+
const isVerbose = flagArgs.has("--verbose");
|
|
22
|
+
const isQuiet = flagArgs.has("--quiet") || flagArgs.has("-q");
|
|
23
|
+
const isNoColor = flagArgs.has("--no-color");
|
|
24
|
+
const isYes = flagArgs.has("--yes") || flagArgs.has("-y");
|
|
25
|
+
|
|
26
|
+
if (isNoColor) process.env["NO_COLOR"] = "1";
|
|
27
|
+
|
|
28
|
+
if (isVerbose && isQuiet) {
|
|
29
|
+
process.stderr.write("Error: --verbose and --quiet are mutually exclusive\n");
|
|
30
|
+
process.exitCode = 2;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ============================================================================
|
|
34
|
+
// CLI
|
|
35
|
+
// ============================================================================
|
|
9
36
|
|
|
10
37
|
const cli = Command.run(command, {
|
|
11
|
-
version
|
|
38
|
+
version,
|
|
12
39
|
});
|
|
13
40
|
|
|
14
41
|
const ServiceLayer = StackService.layer.pipe(
|
|
@@ -18,5 +45,45 @@ const ServiceLayer = StackService.layer.pipe(
|
|
|
18
45
|
|
|
19
46
|
const AppLayer = Layer.mergeAll(ServiceLayer, BunServices.layer);
|
|
20
47
|
|
|
48
|
+
// Usage errors (bad args, invalid state) → exit 2
|
|
49
|
+
// Operational errors (git/gh failures) → exit 1
|
|
50
|
+
const usageCodes = new Set([
|
|
51
|
+
"INVALID_BRANCH_NAME",
|
|
52
|
+
"BRANCH_EXISTS",
|
|
53
|
+
"NOT_IN_STACK",
|
|
54
|
+
"STACK_NOT_FOUND",
|
|
55
|
+
"STACK_EMPTY",
|
|
56
|
+
"ALREADY_AT_TOP",
|
|
57
|
+
"ALREADY_AT_BOTTOM",
|
|
58
|
+
"TRUNK_ERROR",
|
|
59
|
+
"STACK_EXISTS",
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
const handleKnownError = (e: { message: string; code?: string | undefined }) =>
|
|
63
|
+
Console.error(e.code !== undefined ? `Error [${e.code}]: ${e.message}` : e.message).pipe(
|
|
64
|
+
Effect.andThen(
|
|
65
|
+
Effect.sync(() => {
|
|
66
|
+
process.exitCode = e.code !== undefined && usageCodes.has(e.code) ? 2 : 1;
|
|
67
|
+
}),
|
|
68
|
+
),
|
|
69
|
+
);
|
|
70
|
+
|
|
21
71
|
// @effect-diagnostics-next-line effect/strictEffectProvide:off
|
|
22
|
-
BunRuntime.runMain(
|
|
72
|
+
BunRuntime.runMain(
|
|
73
|
+
cli.pipe(
|
|
74
|
+
Effect.provideService(OutputConfig, { verbose: isVerbose, quiet: isQuiet, yes: isYes }),
|
|
75
|
+
Effect.provide(AppLayer),
|
|
76
|
+
Effect.catchTags({
|
|
77
|
+
GitError: (e) => handleKnownError(e),
|
|
78
|
+
StackError: (e) => handleKnownError(e),
|
|
79
|
+
GitHubError: (e) => handleKnownError(e),
|
|
80
|
+
}),
|
|
81
|
+
Effect.catch((e) => {
|
|
82
|
+
const msg =
|
|
83
|
+
e !== null && typeof e === "object" && "message" in e
|
|
84
|
+
? String(e.message)
|
|
85
|
+
: JSON.stringify(e, null, 2);
|
|
86
|
+
return handleKnownError({ message: `Unexpected error: ${msg}` });
|
|
87
|
+
}),
|
|
88
|
+
),
|
|
89
|
+
);
|
package/src/services/Git.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
1
2
|
import { Effect, Layer, ServiceMap } from "effect";
|
|
2
3
|
import { GitError } from "../errors/index.js";
|
|
3
4
|
|
|
@@ -5,38 +6,69 @@ export class GitService extends ServiceMap.Service<
|
|
|
5
6
|
GitService,
|
|
6
7
|
{
|
|
7
8
|
readonly currentBranch: () => Effect.Effect<string, GitError>;
|
|
9
|
+
readonly listBranches: () => Effect.Effect<string[], GitError>;
|
|
8
10
|
readonly branchExists: (name: string) => Effect.Effect<boolean, GitError>;
|
|
9
11
|
readonly createBranch: (name: string, from?: string) => Effect.Effect<void, GitError>;
|
|
10
12
|
readonly deleteBranch: (name: string, force?: boolean) => Effect.Effect<void, GitError>;
|
|
11
13
|
readonly checkout: (name: string) => Effect.Effect<void, GitError>;
|
|
12
14
|
readonly rebase: (onto: string) => Effect.Effect<void, GitError>;
|
|
15
|
+
readonly rebaseOnto: (
|
|
16
|
+
branch: string,
|
|
17
|
+
newBase: string,
|
|
18
|
+
oldBase: string,
|
|
19
|
+
) => Effect.Effect<void, GitError>;
|
|
20
|
+
readonly rebaseAbort: () => Effect.Effect<void, GitError>;
|
|
13
21
|
readonly push: (branch: string, options?: { force?: boolean }) => Effect.Effect<void, GitError>;
|
|
14
22
|
readonly log: (
|
|
15
23
|
branch: string,
|
|
16
24
|
options?: { limit?: number; oneline?: boolean },
|
|
17
25
|
) => Effect.Effect<string, GitError>;
|
|
18
|
-
readonly mergeBase: (a: string, b: string) => Effect.Effect<string, GitError>;
|
|
19
26
|
readonly isClean: () => Effect.Effect<boolean, GitError>;
|
|
20
27
|
readonly revParse: (ref: string) => Effect.Effect<string, GitError>;
|
|
21
|
-
readonly diff: (
|
|
22
|
-
a: string,
|
|
23
|
-
b: string,
|
|
24
|
-
options?: { stat?: boolean },
|
|
25
|
-
) => Effect.Effect<string, GitError>;
|
|
26
28
|
readonly isAncestor: (ancestor: string, descendant: string) => Effect.Effect<boolean, GitError>;
|
|
27
|
-
readonly
|
|
29
|
+
readonly mergeBase: (a: string, b: string) => Effect.Effect<string, GitError>;
|
|
30
|
+
readonly isRebaseInProgress: () => Effect.Effect<boolean>;
|
|
31
|
+
readonly commitAmend: (options?: { edit?: boolean }) => Effect.Effect<void, GitError>;
|
|
28
32
|
readonly fetch: (remote?: string) => Effect.Effect<void, GitError>;
|
|
33
|
+
readonly deleteRemoteBranch: (branch: string) => Effect.Effect<void, GitError>;
|
|
29
34
|
}
|
|
30
35
|
>()("@cvr/stacked/services/Git/GitService") {
|
|
31
36
|
static layer: Layer.Layer<GitService> = Layer.sync(GitService, () => {
|
|
32
37
|
const run = Effect.fn("git.run")(function* (args: readonly string[]) {
|
|
33
|
-
const proc =
|
|
34
|
-
|
|
35
|
-
|
|
38
|
+
const proc = yield* Effect.sync(() =>
|
|
39
|
+
Bun.spawn(["git", ...args], {
|
|
40
|
+
stdout: "pipe",
|
|
41
|
+
stderr: "pipe",
|
|
42
|
+
}),
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const exitCode = yield* Effect.tryPromise({
|
|
46
|
+
try: () => proc.exited,
|
|
47
|
+
catch: (e) =>
|
|
48
|
+
new GitError({ message: `Process failed: ${e}`, command: `git ${args.join(" ")}` }),
|
|
49
|
+
}).pipe(
|
|
50
|
+
Effect.onInterrupt(() =>
|
|
51
|
+
Effect.sync(() => {
|
|
52
|
+
proc.kill();
|
|
53
|
+
}),
|
|
54
|
+
),
|
|
55
|
+
);
|
|
56
|
+
const stdout = yield* Effect.tryPromise({
|
|
57
|
+
try: () => new Response(proc.stdout).text(),
|
|
58
|
+
catch: (e) =>
|
|
59
|
+
new GitError({
|
|
60
|
+
message: `Failed to read stdout: ${e}`,
|
|
61
|
+
command: `git ${args.join(" ")}`,
|
|
62
|
+
}),
|
|
63
|
+
});
|
|
64
|
+
const stderr = yield* Effect.tryPromise({
|
|
65
|
+
try: () => new Response(proc.stderr).text(),
|
|
66
|
+
catch: (e) =>
|
|
67
|
+
new GitError({
|
|
68
|
+
message: `Failed to read stderr: ${e}`,
|
|
69
|
+
command: `git ${args.join(" ")}`,
|
|
70
|
+
}),
|
|
36
71
|
});
|
|
37
|
-
const exitCode = yield* Effect.promise(() => proc.exited);
|
|
38
|
-
const stdout = yield* Effect.promise(() => new Response(proc.stdout).text());
|
|
39
|
-
const stderr = yield* Effect.promise(() => new Response(proc.stderr).text());
|
|
40
72
|
|
|
41
73
|
if (exitCode !== 0) {
|
|
42
74
|
return yield* new GitError({
|
|
@@ -48,12 +80,32 @@ export class GitService extends ServiceMap.Service<
|
|
|
48
80
|
});
|
|
49
81
|
|
|
50
82
|
return {
|
|
51
|
-
currentBranch: () =>
|
|
83
|
+
currentBranch: () =>
|
|
84
|
+
run(["rev-parse", "--abbrev-ref", "HEAD"]).pipe(
|
|
85
|
+
Effect.filterOrFail(
|
|
86
|
+
(branch) => branch !== "HEAD",
|
|
87
|
+
() =>
|
|
88
|
+
new GitError({
|
|
89
|
+
message: "HEAD is detached — checkout a branch first",
|
|
90
|
+
command: "git rev-parse --abbrev-ref HEAD",
|
|
91
|
+
}),
|
|
92
|
+
),
|
|
93
|
+
),
|
|
94
|
+
|
|
95
|
+
listBranches: () =>
|
|
96
|
+
run(["branch", "--format=%(refname:short)"]).pipe(
|
|
97
|
+
Effect.map((output) =>
|
|
98
|
+
output
|
|
99
|
+
.split("\n")
|
|
100
|
+
.map((b) => b.trim())
|
|
101
|
+
.filter((b) => b.length > 0),
|
|
102
|
+
),
|
|
103
|
+
),
|
|
52
104
|
|
|
53
105
|
branchExists: (name) =>
|
|
54
|
-
run(["rev-parse", "--verify", name]).pipe(
|
|
106
|
+
run(["rev-parse", "--verify", `refs/heads/${name}`]).pipe(
|
|
55
107
|
Effect.as(true),
|
|
56
|
-
Effect.
|
|
108
|
+
Effect.catchTag("GitError", () => Effect.succeed(false)),
|
|
57
109
|
),
|
|
58
110
|
|
|
59
111
|
createBranch: (name, from) => {
|
|
@@ -62,15 +114,21 @@ export class GitService extends ServiceMap.Service<
|
|
|
62
114
|
},
|
|
63
115
|
|
|
64
116
|
deleteBranch: (name, force) =>
|
|
65
|
-
run(["branch", force === true ? "-D" : "-d", name]).pipe(Effect.asVoid),
|
|
117
|
+
run(["branch", force === true ? "-D" : "-d", "--", name]).pipe(Effect.asVoid),
|
|
66
118
|
|
|
67
119
|
checkout: (name) => run(["checkout", name]).pipe(Effect.asVoid),
|
|
68
120
|
|
|
69
121
|
rebase: (onto) => run(["rebase", onto]).pipe(Effect.asVoid),
|
|
70
122
|
|
|
123
|
+
rebaseOnto: (branch, newBase, oldBase) =>
|
|
124
|
+
run(["rebase", "--onto", newBase, oldBase, branch]).pipe(Effect.asVoid),
|
|
125
|
+
|
|
126
|
+
rebaseAbort: () => run(["rebase", "--abort"]).pipe(Effect.asVoid),
|
|
127
|
+
|
|
71
128
|
push: (branch, options) => {
|
|
72
|
-
const args = ["push", "-u", "origin"
|
|
129
|
+
const args = ["push", "-u", "origin"];
|
|
73
130
|
if (options?.force === true) args.splice(1, 0, "--force-with-lease");
|
|
131
|
+
args.push(branch);
|
|
74
132
|
return run(args).pipe(Effect.asVoid);
|
|
75
133
|
},
|
|
76
134
|
|
|
@@ -81,47 +139,61 @@ export class GitService extends ServiceMap.Service<
|
|
|
81
139
|
return run(args);
|
|
82
140
|
},
|
|
83
141
|
|
|
84
|
-
mergeBase: (a, b) => run(["merge-base", a, b]),
|
|
85
|
-
|
|
86
142
|
isClean: () => run(["status", "--porcelain"]).pipe(Effect.map((r) => r === "")),
|
|
87
143
|
|
|
88
144
|
revParse: (ref) => run(["rev-parse", ref]),
|
|
89
145
|
|
|
90
|
-
diff: (a, b, options) => {
|
|
91
|
-
const args = ["diff", a, b];
|
|
92
|
-
if (options?.stat === true) args.push("--stat");
|
|
93
|
-
return run(args);
|
|
94
|
-
},
|
|
95
|
-
|
|
96
146
|
isAncestor: (ancestor, descendant) =>
|
|
97
147
|
run(["merge-base", "--is-ancestor", ancestor, descendant]).pipe(
|
|
98
148
|
Effect.as(true),
|
|
149
|
+
Effect.catchTag("GitError", () => Effect.succeed(false)),
|
|
150
|
+
),
|
|
151
|
+
|
|
152
|
+
mergeBase: (a, b) => run(["merge-base", a, b]),
|
|
153
|
+
|
|
154
|
+
isRebaseInProgress: () =>
|
|
155
|
+
run(["rev-parse", "--git-dir"]).pipe(
|
|
156
|
+
Effect.map(
|
|
157
|
+
(gitDir) =>
|
|
158
|
+
existsSync(`${gitDir}/rebase-merge`) || existsSync(`${gitDir}/rebase-apply`),
|
|
159
|
+
),
|
|
99
160
|
Effect.catch(() => Effect.succeed(false)),
|
|
100
161
|
),
|
|
101
162
|
|
|
102
|
-
|
|
163
|
+
commitAmend: (options) => {
|
|
164
|
+
const args = ["commit", "--amend"];
|
|
165
|
+
if (options?.edit !== true) args.push("--no-edit");
|
|
166
|
+
return run(args).pipe(Effect.asVoid);
|
|
167
|
+
},
|
|
103
168
|
|
|
104
169
|
fetch: (remote) => run(["fetch", remote ?? "origin"]).pipe(Effect.asVoid),
|
|
170
|
+
|
|
171
|
+
deleteRemoteBranch: (branch) =>
|
|
172
|
+
run(["push", "origin", "--delete", branch]).pipe(Effect.asVoid),
|
|
105
173
|
};
|
|
106
174
|
});
|
|
107
175
|
|
|
108
176
|
static layerTest = (impl: Partial<ServiceMap.Service.Shape<typeof GitService>> = {}) =>
|
|
109
177
|
Layer.succeed(GitService, {
|
|
110
178
|
currentBranch: () => Effect.succeed("main"),
|
|
179
|
+
listBranches: () => Effect.succeed([]),
|
|
111
180
|
branchExists: () => Effect.succeed(false),
|
|
112
181
|
createBranch: () => Effect.void,
|
|
113
182
|
deleteBranch: () => Effect.void,
|
|
114
183
|
checkout: () => Effect.void,
|
|
115
184
|
rebase: () => Effect.void,
|
|
185
|
+
rebaseOnto: () => Effect.void,
|
|
186
|
+
rebaseAbort: () => Effect.void,
|
|
116
187
|
push: () => Effect.void,
|
|
117
188
|
log: () => Effect.succeed(""),
|
|
118
|
-
mergeBase: () => Effect.succeed("abc123"),
|
|
119
189
|
isClean: () => Effect.succeed(true),
|
|
120
190
|
revParse: () => Effect.succeed("abc123"),
|
|
121
|
-
diff: () => Effect.succeed(""),
|
|
122
191
|
isAncestor: () => Effect.succeed(true),
|
|
123
|
-
|
|
192
|
+
mergeBase: () => Effect.succeed("abc123"),
|
|
193
|
+
isRebaseInProgress: () => Effect.succeed(false),
|
|
194
|
+
commitAmend: () => Effect.void,
|
|
124
195
|
fetch: () => Effect.void,
|
|
196
|
+
deleteRemoteBranch: () => Effect.void,
|
|
125
197
|
...impl,
|
|
126
198
|
});
|
|
127
199
|
}
|
package/src/services/GitHub.ts
CHANGED
|
@@ -6,6 +6,7 @@ const GhPrResponse = Schema.Struct({
|
|
|
6
6
|
url: Schema.String,
|
|
7
7
|
state: Schema.String,
|
|
8
8
|
baseRefName: Schema.String,
|
|
9
|
+
body: Schema.NullOr(Schema.String),
|
|
9
10
|
});
|
|
10
11
|
|
|
11
12
|
export class GitHubService extends ServiceMap.Service<
|
|
@@ -27,7 +28,7 @@ export class GitHubService extends ServiceMap.Service<
|
|
|
27
28
|
readonly getPR: (
|
|
28
29
|
branch: string,
|
|
29
30
|
) => Effect.Effect<
|
|
30
|
-
{ number: number; url: string; state: string; base: string } | null,
|
|
31
|
+
{ number: number; url: string; state: string; base: string; body: string | null } | null,
|
|
31
32
|
GitHubError
|
|
32
33
|
>;
|
|
33
34
|
readonly isGhInstalled: () => Effect.Effect<boolean>;
|
|
@@ -35,13 +36,40 @@ export class GitHubService extends ServiceMap.Service<
|
|
|
35
36
|
>()("@cvr/stacked/services/GitHub/GitHubService") {
|
|
36
37
|
static layer: Layer.Layer<GitHubService> = Layer.sync(GitHubService, () => {
|
|
37
38
|
const run = Effect.fn("gh.run")(function* (args: readonly string[]) {
|
|
38
|
-
const proc =
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
const proc = yield* Effect.sync(() =>
|
|
40
|
+
Bun.spawn(["gh", ...args], {
|
|
41
|
+
stdout: "pipe",
|
|
42
|
+
stderr: "pipe",
|
|
43
|
+
}),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const exitCode = yield* Effect.tryPromise({
|
|
47
|
+
try: () => proc.exited,
|
|
48
|
+
catch: (e) =>
|
|
49
|
+
new GitHubError({ message: `Process failed: ${e}`, command: `gh ${args.join(" ")}` }),
|
|
50
|
+
}).pipe(
|
|
51
|
+
Effect.onInterrupt(() =>
|
|
52
|
+
Effect.sync(() => {
|
|
53
|
+
proc.kill();
|
|
54
|
+
}),
|
|
55
|
+
),
|
|
56
|
+
);
|
|
57
|
+
const stdout = yield* Effect.tryPromise({
|
|
58
|
+
try: () => new Response(proc.stdout).text(),
|
|
59
|
+
catch: (e) =>
|
|
60
|
+
new GitHubError({
|
|
61
|
+
message: `Failed to read stdout: ${e}`,
|
|
62
|
+
command: `gh ${args.join(" ")}`,
|
|
63
|
+
}),
|
|
64
|
+
});
|
|
65
|
+
const stderr = yield* Effect.tryPromise({
|
|
66
|
+
try: () => new Response(proc.stderr).text(),
|
|
67
|
+
catch: (e) =>
|
|
68
|
+
new GitHubError({
|
|
69
|
+
message: `Failed to read stderr: ${e}`,
|
|
70
|
+
command: `gh ${args.join(" ")}`,
|
|
71
|
+
}),
|
|
41
72
|
});
|
|
42
|
-
const exitCode = yield* Effect.promise(() => proc.exited);
|
|
43
|
-
const stdout = yield* Effect.promise(() => new Response(proc.stdout).text());
|
|
44
|
-
const stderr = yield* Effect.promise(() => new Response(proc.stderr).text());
|
|
45
73
|
|
|
46
74
|
if (exitCode !== 0) {
|
|
47
75
|
return yield* new GitHubError({
|
|
@@ -87,31 +115,41 @@ export class GitHubService extends ServiceMap.Service<
|
|
|
87
115
|
"view",
|
|
88
116
|
branch,
|
|
89
117
|
"--json",
|
|
90
|
-
"number,url,state,baseRefName",
|
|
91
|
-
]).pipe(Effect.
|
|
118
|
+
"number,url,state,baseRefName,body",
|
|
119
|
+
]).pipe(Effect.catchTag("GitHubError", () => Effect.succeed(null)));
|
|
92
120
|
|
|
93
121
|
if (result === null) return null;
|
|
94
122
|
const data = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(GhPrResponse))(
|
|
95
123
|
result,
|
|
96
|
-
).pipe(Effect.
|
|
124
|
+
).pipe(Effect.catchTag("SchemaError", () => Effect.succeed(null)));
|
|
97
125
|
if (data === null) return null;
|
|
98
126
|
return {
|
|
99
127
|
number: data.number,
|
|
100
128
|
url: data.url,
|
|
101
129
|
state: data.state,
|
|
102
130
|
base: data.baseRefName,
|
|
131
|
+
body: data.body,
|
|
103
132
|
};
|
|
104
133
|
}),
|
|
105
134
|
|
|
106
135
|
isGhInstalled: () =>
|
|
107
|
-
Effect.
|
|
108
|
-
try
|
|
109
|
-
Bun.
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
})
|
|
136
|
+
Effect.try({
|
|
137
|
+
try: () =>
|
|
138
|
+
Bun.spawn(["gh", "--version"], {
|
|
139
|
+
stdout: "ignore",
|
|
140
|
+
stderr: "ignore",
|
|
141
|
+
}),
|
|
142
|
+
catch: () => null,
|
|
143
|
+
}).pipe(
|
|
144
|
+
Effect.andThen((proc) => {
|
|
145
|
+
if (proc === null) return Effect.succeed(false);
|
|
146
|
+
return Effect.tryPromise({
|
|
147
|
+
try: () => proc.exited,
|
|
148
|
+
catch: () => -1,
|
|
149
|
+
}).pipe(Effect.map((code) => code === 0));
|
|
150
|
+
}),
|
|
151
|
+
Effect.catch(() => Effect.succeed(false)),
|
|
152
|
+
),
|
|
115
153
|
};
|
|
116
154
|
});
|
|
117
155
|
|
package/src/services/Stack.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Effect, Layer, Ref, Schema, ServiceMap } from "effect";
|
|
2
|
+
import { rename } from "node:fs/promises";
|
|
2
3
|
import type { GitError } from "../errors/index.js";
|
|
3
4
|
import { StackError } from "../errors/index.js";
|
|
4
5
|
import { GitService } from "./Git.js";
|
|
@@ -34,10 +35,11 @@ export class StackService extends ServiceMap.Service<
|
|
|
34
35
|
) => Effect.Effect<void, StackError>;
|
|
35
36
|
readonly removeBranch: (stackName: string, branch: string) => Effect.Effect<void, StackError>;
|
|
36
37
|
readonly createStack: (name: string, branches: string[]) => Effect.Effect<void, StackError>;
|
|
38
|
+
readonly findBranchStack: (
|
|
39
|
+
branch: string,
|
|
40
|
+
) => Effect.Effect<{ name: string; stack: Stack } | null, StackError>;
|
|
37
41
|
readonly getTrunk: () => Effect.Effect<string, StackError>;
|
|
38
42
|
readonly setTrunk: (name: string) => Effect.Effect<void, StackError>;
|
|
39
|
-
readonly parentOf: (branch: string) => Effect.Effect<string, StackError>;
|
|
40
|
-
readonly childrenOf: (branch: string) => Effect.Effect<string[], StackError>;
|
|
41
43
|
}
|
|
42
44
|
>()("@cvr/stacked/services/Stack/StackService") {
|
|
43
45
|
static layer: Layer.Layer<StackService, never, GitService> = Layer.effect(
|
|
@@ -47,7 +49,7 @@ export class StackService extends ServiceMap.Service<
|
|
|
47
49
|
|
|
48
50
|
const stackFilePath = Effect.fn("stackFilePath")(function* () {
|
|
49
51
|
const gitDir = yield* git
|
|
50
|
-
.revParse("--git-dir")
|
|
52
|
+
.revParse("--absolute-git-dir")
|
|
51
53
|
.pipe(
|
|
52
54
|
Effect.mapError(
|
|
53
55
|
(e) => new StackError({ message: `Not a git repository: ${e.message}` }),
|
|
@@ -60,25 +62,64 @@ export class StackService extends ServiceMap.Service<
|
|
|
60
62
|
const decodeStackFile = Schema.decodeUnknownEffect(StackFileJson);
|
|
61
63
|
const encodeStackFile = Schema.encodeEffect(StackFileJson);
|
|
62
64
|
|
|
65
|
+
const detectTrunk = Effect.fn("StackService.detectTrunk")(function* () {
|
|
66
|
+
// Check common default branch names
|
|
67
|
+
for (const candidate of ["main", "master", "develop"]) {
|
|
68
|
+
const exists = yield* git
|
|
69
|
+
.branchExists(candidate)
|
|
70
|
+
.pipe(Effect.catchTag("GitError", () => Effect.succeed(false)));
|
|
71
|
+
if (exists) return candidate;
|
|
72
|
+
}
|
|
73
|
+
return "main";
|
|
74
|
+
});
|
|
75
|
+
|
|
63
76
|
const load = Effect.fn("StackService.load")(function* () {
|
|
64
77
|
const path = yield* stackFilePath();
|
|
65
78
|
const file = Bun.file(path);
|
|
66
|
-
const exists = yield* Effect.
|
|
67
|
-
|
|
68
|
-
|
|
79
|
+
const exists = yield* Effect.tryPromise({
|
|
80
|
+
try: () => file.exists(),
|
|
81
|
+
catch: () => new StackError({ message: `Failed to check if ${path} exists` }),
|
|
82
|
+
});
|
|
83
|
+
if (!exists) {
|
|
84
|
+
const trunk = yield* detectTrunk();
|
|
85
|
+
return { ...emptyStackFile, trunk } satisfies StackFile;
|
|
86
|
+
}
|
|
87
|
+
const text = yield* Effect.tryPromise({
|
|
88
|
+
try: () => file.text(),
|
|
89
|
+
catch: () => new StackError({ message: `Failed to read ${path}` }),
|
|
90
|
+
});
|
|
69
91
|
return yield* decodeStackFile(text).pipe(
|
|
70
|
-
Effect.
|
|
92
|
+
Effect.catchTag("SchemaError", (e) =>
|
|
93
|
+
Effect.gen(function* () {
|
|
94
|
+
const backupPath = `${path}.backup`;
|
|
95
|
+
yield* Effect.tryPromise({
|
|
96
|
+
try: () => Bun.write(backupPath, text),
|
|
97
|
+
catch: () => new StackError({ message: `Failed to write backup to ${backupPath}` }),
|
|
98
|
+
});
|
|
99
|
+
yield* Effect.logWarning(
|
|
100
|
+
`Corrupted stack file, resetting: ${e.message}\nBackup saved to ${backupPath}`,
|
|
101
|
+
);
|
|
102
|
+
const trunk = yield* detectTrunk();
|
|
103
|
+
return { ...emptyStackFile, trunk } satisfies StackFile;
|
|
104
|
+
}),
|
|
105
|
+
),
|
|
71
106
|
);
|
|
72
107
|
});
|
|
73
108
|
|
|
74
109
|
const save = Effect.fn("StackService.save")(function* (data: StackFile) {
|
|
75
110
|
const path = yield* stackFilePath();
|
|
111
|
+
const tmpPath = `${path}.tmp`;
|
|
76
112
|
const text = yield* encodeStackFile(data).pipe(
|
|
77
113
|
Effect.mapError(() => new StackError({ message: `Failed to encode stack data` })),
|
|
78
114
|
);
|
|
79
|
-
yield* Effect.
|
|
80
|
-
|
|
81
|
-
|
|
115
|
+
yield* Effect.tryPromise({
|
|
116
|
+
try: () => Bun.write(tmpPath, text + "\n"),
|
|
117
|
+
catch: () => new StackError({ message: `Failed to write ${tmpPath}` }),
|
|
118
|
+
});
|
|
119
|
+
yield* Effect.tryPromise({
|
|
120
|
+
try: () => rename(tmpPath, path),
|
|
121
|
+
catch: () => new StackError({ message: `Failed to rename ${tmpPath} to ${path}` }),
|
|
122
|
+
});
|
|
82
123
|
});
|
|
83
124
|
|
|
84
125
|
const findBranchStack = (data: StackFile, branch: string) => {
|
|
@@ -94,6 +135,9 @@ export class StackService extends ServiceMap.Service<
|
|
|
94
135
|
load: () => load(),
|
|
95
136
|
save: (data) => save(data),
|
|
96
137
|
|
|
138
|
+
findBranchStack: (branch: string) =>
|
|
139
|
+
load().pipe(Effect.map((data) => findBranchStack(data, branch))),
|
|
140
|
+
|
|
97
141
|
currentStack: Effect.fn("StackService.currentStack")(function* () {
|
|
98
142
|
const branch = yield* git.currentBranch();
|
|
99
143
|
const data = yield* load();
|
|
@@ -106,6 +150,12 @@ export class StackService extends ServiceMap.Service<
|
|
|
106
150
|
after?: string,
|
|
107
151
|
) {
|
|
108
152
|
const data = yield* load();
|
|
153
|
+
const existing = findBranchStack(data, branch);
|
|
154
|
+
if (existing !== null) {
|
|
155
|
+
return yield* new StackError({
|
|
156
|
+
message: `Branch "${branch}" is already in stack "${existing.name}"`,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
109
159
|
const stack = data.stacks[stackName];
|
|
110
160
|
if (stack === undefined) {
|
|
111
161
|
return yield* new StackError({ message: `Stack "${stackName}" not found` });
|
|
@@ -172,34 +222,11 @@ export class StackService extends ServiceMap.Service<
|
|
|
172
222
|
const data = yield* load();
|
|
173
223
|
yield* save({ ...data, trunk: name });
|
|
174
224
|
}),
|
|
175
|
-
|
|
176
|
-
parentOf: Effect.fn("StackService.parentOf")(function* (branch: string) {
|
|
177
|
-
const data = yield* load();
|
|
178
|
-
for (const stack of Object.values(data.stacks)) {
|
|
179
|
-
const idx = stack.branches.indexOf(branch);
|
|
180
|
-
if (idx === 0) return data.trunk;
|
|
181
|
-
if (idx > 0) return stack.branches[idx - 1] ?? data.trunk;
|
|
182
|
-
}
|
|
183
|
-
return yield* new StackError({ message: `Branch "${branch}" not found in any stack` });
|
|
184
|
-
}),
|
|
185
|
-
|
|
186
|
-
childrenOf: Effect.fn("StackService.childrenOf")(function* (branch: string) {
|
|
187
|
-
const data = yield* load();
|
|
188
|
-
const children: string[] = [];
|
|
189
|
-
for (const stack of Object.values(data.stacks)) {
|
|
190
|
-
const idx = stack.branches.indexOf(branch);
|
|
191
|
-
const child = stack.branches[idx + 1];
|
|
192
|
-
if (idx !== -1 && child !== undefined) {
|
|
193
|
-
children.push(child);
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
return children;
|
|
197
|
-
}),
|
|
198
225
|
};
|
|
199
226
|
}),
|
|
200
227
|
);
|
|
201
228
|
|
|
202
|
-
static layerTest = (data?: StackFile) => {
|
|
229
|
+
static layerTest = (data?: StackFile, options?: { currentBranch?: string }) => {
|
|
203
230
|
const initial = data ?? emptyStackFile;
|
|
204
231
|
return Layer.effect(
|
|
205
232
|
StackService,
|
|
@@ -219,9 +246,12 @@ export class StackService extends ServiceMap.Service<
|
|
|
219
246
|
load: () => Ref.get(ref),
|
|
220
247
|
save: (d) => Ref.set(ref, d),
|
|
221
248
|
|
|
249
|
+
findBranchStack: (branch: string) =>
|
|
250
|
+
Ref.get(ref).pipe(Effect.map((d) => findBranchStack(d, branch))),
|
|
251
|
+
|
|
222
252
|
currentStack: Effect.fn("test.currentStack")(function* () {
|
|
223
253
|
const d = yield* Ref.get(ref);
|
|
224
|
-
return findBranchStack(d, "test-branch");
|
|
254
|
+
return findBranchStack(d, options?.currentBranch ?? "test-branch");
|
|
225
255
|
}),
|
|
226
256
|
|
|
227
257
|
addBranch: Effect.fn("test.addBranch")(function* (
|
|
@@ -269,29 +299,6 @@ export class StackService extends ServiceMap.Service<
|
|
|
269
299
|
|
|
270
300
|
getTrunk: () => Ref.get(ref).pipe(Effect.map((d) => d.trunk)),
|
|
271
301
|
setTrunk: (name: string) => Ref.update(ref, (d) => ({ ...d, trunk: name })),
|
|
272
|
-
|
|
273
|
-
parentOf: Effect.fn("test.parentOf")(function* (branch: string) {
|
|
274
|
-
const d = yield* Ref.get(ref);
|
|
275
|
-
for (const stack of Object.values(d.stacks)) {
|
|
276
|
-
const idx = stack.branches.indexOf(branch);
|
|
277
|
-
if (idx === 0) return d.trunk;
|
|
278
|
-
if (idx > 0) return stack.branches[idx - 1] ?? d.trunk;
|
|
279
|
-
}
|
|
280
|
-
return yield* new StackError({ message: `Branch "${branch}" not found in any stack` });
|
|
281
|
-
}),
|
|
282
|
-
|
|
283
|
-
childrenOf: Effect.fn("test.childrenOf")(function* (branch: string) {
|
|
284
|
-
const d = yield* Ref.get(ref);
|
|
285
|
-
const children: string[] = [];
|
|
286
|
-
for (const stack of Object.values(d.stacks)) {
|
|
287
|
-
const idx = stack.branches.indexOf(branch);
|
|
288
|
-
const child = stack.branches[idx + 1];
|
|
289
|
-
if (idx !== -1 && child !== undefined) {
|
|
290
|
-
children.push(child);
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
return children;
|
|
294
|
-
}),
|
|
295
302
|
};
|
|
296
303
|
}),
|
|
297
304
|
);
|