@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.
@@ -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
  }) {}
@@ -0,0 +1,2 @@
1
+ declare const __VERSION__: string;
2
+ declare const __SKILL_CONTENT__: string;
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: "0.1.0",
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(cli.pipe(Effect.provide(AppLayer)));
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
+ );
@@ -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 remote: () => Effect.Effect<string, GitError>;
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 = Bun.spawn(["git", ...args], {
34
- stdout: "pipe",
35
- stderr: "pipe",
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: () => run(["rev-parse", "--abbrev-ref", "HEAD"]),
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.catch(() => Effect.succeed(false)),
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", branch];
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
- remote: () => run(["remote"]),
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
- remote: () => Effect.succeed("origin"),
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
  }
@@ -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 = Bun.spawn(["gh", ...args], {
39
- stdout: "pipe",
40
- stderr: "pipe",
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.catch(() => Effect.succeed(null)));
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.catch(() => Effect.succeed(null)));
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.sync(() => {
108
- try {
109
- Bun.spawnSync(["gh", "--version"]);
110
- return true;
111
- } catch {
112
- return false;
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
 
@@ -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.promise(() => file.exists());
67
- if (!exists) return emptyStackFile;
68
- const text = yield* Effect.promise(() => file.text());
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.catch(() => Effect.succeed(emptyStackFile)),
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.promise(() => Bun.write(path, text + "\n")).pipe(
80
- Effect.mapError(() => new StackError({ message: `Failed to write ${path}` })),
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
  );