@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.
@@ -0,0 +1,55 @@
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 up = Command.make("up", { json: jsonFlag }).pipe(
11
+ Command.withDescription("Move up one branch in the stack"),
12
+ Command.withExamples([{ command: "stacked up", description: "Move to the next branch above" }]),
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
+ const next = branches[idx + 1];
38
+ if (next === undefined) {
39
+ return yield* new StackError({
40
+ code: ErrorCode.ALREADY_AT_TOP,
41
+ message: "Already at the top of the stack",
42
+ });
43
+ }
44
+
45
+ yield* git.checkout(next);
46
+
47
+ if (json) {
48
+ // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
49
+ yield* Console.log(JSON.stringify({ branch: next, from: currentBranch }, null, 2));
50
+ } else {
51
+ yield* success(`Switched to ${next}`);
52
+ }
53
+ }),
54
+ ),
55
+ );
@@ -1,15 +1,51 @@
1
- import { Schema } from "effect";
1
+ import { Runtime, Schema } from "effect";
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
+ // ============================================================================
2
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
  }) {}
46
+
47
+ export class GlobalFlagConflictError extends Error {
48
+ override readonly name = "GlobalFlagConflictError";
49
+ override readonly [Runtime.errorExitCode] = 2;
50
+ override readonly [Runtime.errorReported] = false;
51
+ }
@@ -0,0 +1,2 @@
1
+ declare const __VERSION__: string;
2
+ declare const __SKILL_CONTENT__: string;
package/src/main.ts CHANGED
@@ -1,14 +1,44 @@
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
+ import { GlobalFlagConflictError } from "./errors/index.js";
11
+
12
+ const version = typeof __VERSION__ !== "undefined" ? __VERSION__ : "dev";
13
+
14
+ // ============================================================================
15
+ // Global Flags (parsed before CLI framework, stripped from argv)
16
+ // ============================================================================
17
+
18
+ const globalFlags = new Set(["--verbose", "--quiet", "-q", "--no-color", "--yes", "-y"]);
19
+ const flagArgs = new Set(process.argv.filter((a) => globalFlags.has(a)));
20
+ process.argv = process.argv.filter((a) => !globalFlags.has(a));
21
+
22
+ const isVerbose = flagArgs.has("--verbose");
23
+ const isQuiet = flagArgs.has("--quiet") || flagArgs.has("-q");
24
+ const isNoColor = flagArgs.has("--no-color");
25
+ const isYes = flagArgs.has("--yes") || flagArgs.has("-y");
26
+
27
+ if (isNoColor) process.env["NO_COLOR"] = "1";
28
+
29
+ const preflight =
30
+ isVerbose && isQuiet
31
+ ? Console.error("Error: --verbose and --quiet are mutually exclusive").pipe(
32
+ Effect.andThen(Effect.fail(new GlobalFlagConflictError())),
33
+ )
34
+ : Effect.void;
35
+
36
+ // ============================================================================
37
+ // CLI
38
+ // ============================================================================
9
39
 
10
40
  const cli = Command.run(command, {
11
- version: "0.1.0",
41
+ version,
12
42
  });
13
43
 
14
44
  const ServiceLayer = StackService.layer.pipe(
@@ -18,5 +48,50 @@ const ServiceLayer = StackService.layer.pipe(
18
48
 
19
49
  const AppLayer = Layer.mergeAll(ServiceLayer, BunServices.layer);
20
50
 
51
+ // Usage errors (bad args, invalid state) → exit 2
52
+ // Operational errors (git/gh failures) → exit 1
53
+ const usageCodes = new Set([
54
+ "INVALID_BRANCH_NAME",
55
+ "BRANCH_EXISTS",
56
+ "NOT_IN_STACK",
57
+ "STACK_NOT_FOUND",
58
+ "STACK_EMPTY",
59
+ "ALREADY_AT_TOP",
60
+ "ALREADY_AT_BOTTOM",
61
+ "TRUNK_ERROR",
62
+ "STACK_EXISTS",
63
+ ]);
64
+
65
+ const handleKnownError = (e: { message: string; code?: string | undefined }) =>
66
+ Console.error(e.code !== undefined ? `Error [${e.code}]: ${e.message}` : e.message).pipe(
67
+ Effect.andThen(
68
+ Effect.sync(() => {
69
+ process.exitCode = e.code !== undefined && usageCodes.has(e.code) ? 2 : 1;
70
+ }),
71
+ ),
72
+ );
73
+
21
74
  // @effect-diagnostics-next-line effect/strictEffectProvide:off
22
- BunRuntime.runMain(cli.pipe(Effect.provide(AppLayer)));
75
+ BunRuntime.runMain(
76
+ preflight.pipe(
77
+ Effect.andThen(cli),
78
+ Effect.provideService(OutputConfig, { verbose: isVerbose, quiet: isQuiet, yes: isYes }),
79
+ Effect.provide(AppLayer),
80
+ Effect.catchTags({
81
+ GitError: (e) => handleKnownError(e),
82
+ StackError: (e) => handleKnownError(e),
83
+ GitHubError: (e) => handleKnownError(e),
84
+ }),
85
+ Effect.catchIf(
86
+ (e): e is GlobalFlagConflictError => e instanceof GlobalFlagConflictError,
87
+ Effect.fail,
88
+ (e) => {
89
+ const msg =
90
+ e !== null && typeof e === "object" && "message" in e
91
+ ? String(e.message)
92
+ : JSON.stringify(e, null, 2);
93
+ return handleKnownError({ message: `Unexpected error: ${msg}` });
94
+ },
95
+ ),
96
+ ),
97
+ );
@@ -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
 
@@ -11,33 +12,63 @@ export class GitService extends ServiceMap.Service<
11
12
  readonly deleteBranch: (name: string, force?: boolean) => Effect.Effect<void, GitError>;
12
13
  readonly checkout: (name: string) => Effect.Effect<void, GitError>;
13
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>;
14
21
  readonly push: (branch: string, options?: { force?: boolean }) => Effect.Effect<void, GitError>;
15
22
  readonly log: (
16
23
  branch: string,
17
24
  options?: { limit?: number; oneline?: boolean },
18
25
  ) => Effect.Effect<string, GitError>;
19
- readonly mergeBase: (a: string, b: string) => Effect.Effect<string, GitError>;
20
26
  readonly isClean: () => Effect.Effect<boolean, GitError>;
21
27
  readonly revParse: (ref: string) => Effect.Effect<string, GitError>;
22
- readonly diff: (
23
- a: string,
24
- b: string,
25
- options?: { stat?: boolean },
26
- ) => Effect.Effect<string, GitError>;
27
28
  readonly isAncestor: (ancestor: string, descendant: string) => Effect.Effect<boolean, GitError>;
28
- 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>;
29
32
  readonly fetch: (remote?: string) => Effect.Effect<void, GitError>;
33
+ readonly deleteRemoteBranch: (branch: string) => Effect.Effect<void, GitError>;
30
34
  }
31
35
  >()("@cvr/stacked/services/Git/GitService") {
32
36
  static layer: Layer.Layer<GitService> = Layer.sync(GitService, () => {
33
37
  const run = Effect.fn("git.run")(function* (args: readonly string[]) {
34
- const proc = Bun.spawn(["git", ...args], {
35
- stdout: "pipe",
36
- 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
+ }),
37
71
  });
38
- const exitCode = yield* Effect.promise(() => proc.exited);
39
- const stdout = yield* Effect.promise(() => new Response(proc.stdout).text());
40
- const stderr = yield* Effect.promise(() => new Response(proc.stderr).text());
41
72
 
42
73
  if (exitCode !== 0) {
43
74
  return yield* new GitError({
@@ -49,10 +80,25 @@ export class GitService extends ServiceMap.Service<
49
80
  });
50
81
 
51
82
  return {
52
- 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
+ ),
53
94
 
54
95
  listBranches: () =>
55
- run(["branch", "--format=%(refname:short)"]).pipe(
96
+ run([
97
+ "for-each-ref",
98
+ "--sort=-committerdate",
99
+ "--format=%(refname:short)",
100
+ "refs/heads",
101
+ ]).pipe(
56
102
  Effect.map((output) =>
57
103
  output
58
104
  .split("\n")
@@ -62,9 +108,9 @@ export class GitService extends ServiceMap.Service<
62
108
  ),
63
109
 
64
110
  branchExists: (name) =>
65
- run(["rev-parse", "--verify", name]).pipe(
111
+ run(["rev-parse", "--verify", `refs/heads/${name}`]).pipe(
66
112
  Effect.as(true),
67
- Effect.catch(() => Effect.succeed(false)),
113
+ Effect.catchTag("GitError", () => Effect.succeed(false)),
68
114
  ),
69
115
 
70
116
  createBranch: (name, from) => {
@@ -73,15 +119,21 @@ export class GitService extends ServiceMap.Service<
73
119
  },
74
120
 
75
121
  deleteBranch: (name, force) =>
76
- run(["branch", force === true ? "-D" : "-d", name]).pipe(Effect.asVoid),
122
+ run(["branch", force === true ? "-D" : "-d", "--", name]).pipe(Effect.asVoid),
77
123
 
78
124
  checkout: (name) => run(["checkout", name]).pipe(Effect.asVoid),
79
125
 
80
126
  rebase: (onto) => run(["rebase", onto]).pipe(Effect.asVoid),
81
127
 
128
+ rebaseOnto: (branch, newBase, oldBase) =>
129
+ run(["rebase", "--onto", newBase, oldBase, branch]).pipe(Effect.asVoid),
130
+
131
+ rebaseAbort: () => run(["rebase", "--abort"]).pipe(Effect.asVoid),
132
+
82
133
  push: (branch, options) => {
83
- const args = ["push", "-u", "origin", branch];
134
+ const args = ["push", "-u", "origin"];
84
135
  if (options?.force === true) args.splice(1, 0, "--force-with-lease");
136
+ args.push(branch);
85
137
  return run(args).pipe(Effect.asVoid);
86
138
  },
87
139
 
@@ -92,27 +144,37 @@ export class GitService extends ServiceMap.Service<
92
144
  return run(args);
93
145
  },
94
146
 
95
- mergeBase: (a, b) => run(["merge-base", a, b]),
96
-
97
147
  isClean: () => run(["status", "--porcelain"]).pipe(Effect.map((r) => r === "")),
98
148
 
99
149
  revParse: (ref) => run(["rev-parse", ref]),
100
150
 
101
- diff: (a, b, options) => {
102
- const args = ["diff", a, b];
103
- if (options?.stat === true) args.push("--stat");
104
- return run(args);
105
- },
106
-
107
151
  isAncestor: (ancestor, descendant) =>
108
152
  run(["merge-base", "--is-ancestor", ancestor, descendant]).pipe(
109
153
  Effect.as(true),
154
+ Effect.catchTag("GitError", () => Effect.succeed(false)),
155
+ ),
156
+
157
+ mergeBase: (a, b) => run(["merge-base", a, b]),
158
+
159
+ isRebaseInProgress: () =>
160
+ run(["rev-parse", "--git-dir"]).pipe(
161
+ Effect.map(
162
+ (gitDir) =>
163
+ existsSync(`${gitDir}/rebase-merge`) || existsSync(`${gitDir}/rebase-apply`),
164
+ ),
110
165
  Effect.catch(() => Effect.succeed(false)),
111
166
  ),
112
167
 
113
- remote: () => run(["remote"]),
168
+ commitAmend: (options) => {
169
+ const args = ["commit", "--amend"];
170
+ if (options?.edit !== true) args.push("--no-edit");
171
+ return run(args).pipe(Effect.asVoid);
172
+ },
114
173
 
115
174
  fetch: (remote) => run(["fetch", remote ?? "origin"]).pipe(Effect.asVoid),
175
+
176
+ deleteRemoteBranch: (branch) =>
177
+ run(["push", "origin", "--delete", branch]).pipe(Effect.asVoid),
116
178
  };
117
179
  });
118
180
 
@@ -125,15 +187,18 @@ export class GitService extends ServiceMap.Service<
125
187
  deleteBranch: () => Effect.void,
126
188
  checkout: () => Effect.void,
127
189
  rebase: () => Effect.void,
190
+ rebaseOnto: () => Effect.void,
191
+ rebaseAbort: () => Effect.void,
128
192
  push: () => Effect.void,
129
193
  log: () => Effect.succeed(""),
130
- mergeBase: () => Effect.succeed("abc123"),
131
194
  isClean: () => Effect.succeed(true),
132
195
  revParse: () => Effect.succeed("abc123"),
133
- diff: () => Effect.succeed(""),
134
196
  isAncestor: () => Effect.succeed(true),
135
- remote: () => Effect.succeed("origin"),
197
+ mergeBase: () => Effect.succeed("abc123"),
198
+ isRebaseInProgress: () => Effect.succeed(false),
199
+ commitAmend: () => Effect.void,
136
200
  fetch: () => Effect.void,
201
+ deleteRemoteBranch: () => Effect.void,
137
202
  ...impl,
138
203
  });
139
204
  }
@@ -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