@markjaquith/agency 0.5.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.
Files changed (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +109 -0
  3. package/cli.ts +569 -0
  4. package/index.ts +1 -0
  5. package/package.json +65 -0
  6. package/src/commands/base.test.ts +198 -0
  7. package/src/commands/base.ts +198 -0
  8. package/src/commands/clean.test.ts +299 -0
  9. package/src/commands/clean.ts +320 -0
  10. package/src/commands/emit.test.ts +412 -0
  11. package/src/commands/emit.ts +521 -0
  12. package/src/commands/emitted.test.ts +226 -0
  13. package/src/commands/emitted.ts +57 -0
  14. package/src/commands/init.test.ts +311 -0
  15. package/src/commands/init.ts +140 -0
  16. package/src/commands/merge.test.ts +365 -0
  17. package/src/commands/merge.ts +253 -0
  18. package/src/commands/pull.test.ts +385 -0
  19. package/src/commands/pull.ts +205 -0
  20. package/src/commands/push.test.ts +394 -0
  21. package/src/commands/push.ts +346 -0
  22. package/src/commands/save.test.ts +247 -0
  23. package/src/commands/save.ts +162 -0
  24. package/src/commands/source.test.ts +195 -0
  25. package/src/commands/source.ts +72 -0
  26. package/src/commands/status.test.ts +489 -0
  27. package/src/commands/status.ts +258 -0
  28. package/src/commands/switch.test.ts +194 -0
  29. package/src/commands/switch.ts +84 -0
  30. package/src/commands/task-branching.test.ts +334 -0
  31. package/src/commands/task-edit.test.ts +141 -0
  32. package/src/commands/task-main.test.ts +872 -0
  33. package/src/commands/task.ts +712 -0
  34. package/src/commands/tasks.test.ts +335 -0
  35. package/src/commands/tasks.ts +155 -0
  36. package/src/commands/template-delete.test.ts +178 -0
  37. package/src/commands/template-delete.ts +98 -0
  38. package/src/commands/template-list.test.ts +135 -0
  39. package/src/commands/template-list.ts +87 -0
  40. package/src/commands/template-view.test.ts +158 -0
  41. package/src/commands/template-view.ts +86 -0
  42. package/src/commands/template.test.ts +32 -0
  43. package/src/commands/template.ts +96 -0
  44. package/src/commands/use.test.ts +87 -0
  45. package/src/commands/use.ts +97 -0
  46. package/src/commands/work.test.ts +462 -0
  47. package/src/commands/work.ts +193 -0
  48. package/src/errors.ts +17 -0
  49. package/src/schemas.ts +33 -0
  50. package/src/services/AgencyMetadataService.ts +287 -0
  51. package/src/services/ClaudeService.test.ts +184 -0
  52. package/src/services/ClaudeService.ts +91 -0
  53. package/src/services/ConfigService.ts +115 -0
  54. package/src/services/FileSystemService.ts +222 -0
  55. package/src/services/GitService.ts +751 -0
  56. package/src/services/OpencodeService.ts +263 -0
  57. package/src/services/PromptService.ts +183 -0
  58. package/src/services/TemplateService.ts +75 -0
  59. package/src/test-utils.ts +362 -0
  60. package/src/types/native-exec.d.ts +8 -0
  61. package/src/types.ts +216 -0
  62. package/src/utils/colors.ts +178 -0
  63. package/src/utils/command.ts +17 -0
  64. package/src/utils/effect.ts +281 -0
  65. package/src/utils/exec.ts +48 -0
  66. package/src/utils/paths.ts +51 -0
  67. package/src/utils/pr-branch.test.ts +372 -0
  68. package/src/utils/pr-branch.ts +473 -0
  69. package/src/utils/process.ts +110 -0
  70. package/src/utils/spinner.ts +82 -0
  71. package/templates/AGENCY.md +20 -0
  72. package/templates/AGENTS.md +11 -0
  73. package/templates/CLAUDE.md +3 -0
  74. package/templates/TASK.md +5 -0
  75. package/templates/opencode.json +4 -0
@@ -0,0 +1,751 @@
1
+ import { Effect, Data, pipe } from "effect"
2
+ import { resolve } from "path"
3
+ import { realpath } from "fs/promises"
4
+ import {
5
+ spawnProcess,
6
+ checkExitCodeAndReturnStdout,
7
+ checkExitCodeAndReturnVoid,
8
+ createErrorMapper,
9
+ } from "../utils/process"
10
+
11
+ // Error types for Git operations
12
+ class GitError extends Data.TaggedError("GitError")<{
13
+ message: string
14
+ cause?: unknown
15
+ }> {}
16
+
17
+ class NotInGitRepoError extends Data.TaggedError("NotInGitRepoError")<{
18
+ path: string
19
+ }> {}
20
+
21
+ export class GitCommandError extends Data.TaggedError("GitCommandError")<{
22
+ command: string
23
+ exitCode: number
24
+ stderr: string
25
+ }> {}
26
+
27
+ // Error mapper for git command failures
28
+ const mapToGitCommandError = createErrorMapper(GitCommandError)
29
+
30
+ // Helper to run git commands with proper error handling
31
+ const runGitCommand = (args: readonly string[], cwd: string) =>
32
+ pipe(
33
+ spawnProcess(args, { cwd }),
34
+ Effect.mapError(
35
+ (processError) =>
36
+ new GitCommandError({
37
+ command: processError.command,
38
+ exitCode: processError.exitCode,
39
+ stderr: processError.stderr,
40
+ }),
41
+ ),
42
+ )
43
+
44
+ // Helper to run git commands and check exit code
45
+ const runGitCommandOrFail = (args: readonly string[], cwd: string) =>
46
+ pipe(
47
+ runGitCommand(args, cwd),
48
+ Effect.flatMap(checkExitCodeAndReturnStdout(mapToGitCommandError(args))),
49
+ )
50
+
51
+ // Helper to run git commands that return void
52
+ const runGitCommandVoid = (args: readonly string[], cwd: string) =>
53
+ pipe(
54
+ runGitCommand(args, cwd),
55
+ Effect.flatMap(checkExitCodeAndReturnVoid(mapToGitCommandError(args))),
56
+ )
57
+
58
+ // Helper to get git config value
59
+ const getGitConfigEffect = (key: string, gitRoot: string) =>
60
+ pipe(
61
+ runGitCommand(["git", "config", "--local", "--get", key], gitRoot),
62
+ Effect.map((result) => (result.exitCode === 0 ? result.stdout : null)),
63
+ )
64
+
65
+ // Helper to set git config value
66
+ const setGitConfigEffect = (key: string, value: string, gitRoot: string) =>
67
+ pipe(
68
+ runGitCommandVoid(["git", "config", "--local", key, value], gitRoot),
69
+ Effect.mapError(
70
+ (error) =>
71
+ new GitError({
72
+ message:
73
+ error instanceof GitCommandError
74
+ ? `Failed to set git config ${key}: ${error.stderr}`
75
+ : `Failed to set git config ${key}`,
76
+ cause: error,
77
+ }),
78
+ ),
79
+ )
80
+
81
+ // Helper to check if branch exists
82
+ const branchExistsEffect = (gitRoot: string, branch: string) =>
83
+ Effect.gen(function* () {
84
+ // Get list of remotes to dynamically check if it's a remote branch
85
+ const remotesResult = yield* runGitCommand(["git", "remote"], gitRoot)
86
+ const remotes =
87
+ remotesResult.exitCode === 0 && remotesResult.stdout.trim()
88
+ ? remotesResult.stdout.trim().split("\n")
89
+ : []
90
+
91
+ // Check if branch name starts with any remote prefix
92
+ const hasRemotePrefix = remotes.some((remote) =>
93
+ branch.startsWith(`${remote}/`),
94
+ )
95
+
96
+ const ref = hasRemotePrefix
97
+ ? `refs/remotes/${branch}`
98
+ : `refs/heads/${branch}`
99
+
100
+ const result = yield* runGitCommand(
101
+ ["git", "show-ref", "--verify", "--quiet", ref],
102
+ gitRoot,
103
+ )
104
+ return result.exitCode === 0
105
+ })
106
+
107
+ // Helper to find default remote
108
+ const findDefaultRemoteEffect = (gitRoot: string) =>
109
+ Effect.gen(function* () {
110
+ // Get list of remotes
111
+ const result = yield* runGitCommand(["git", "remote"], gitRoot)
112
+
113
+ if (result.exitCode === 0 && result.stdout.trim()) {
114
+ // Return first remote (usually "origin")
115
+ const remotes = result.stdout.trim().split("\n")
116
+ return remotes[0] || null
117
+ }
118
+
119
+ return null
120
+ })
121
+
122
+ // Helper to find main branch (shared logic)
123
+ const findMainBranchEffect = (gitRoot: string) =>
124
+ Effect.gen(function* () {
125
+ // Get list of remotes
126
+ const remotesResult = yield* runGitCommand(["git", "remote"], gitRoot)
127
+ const remotes =
128
+ remotesResult.exitCode === 0 && remotesResult.stdout.trim()
129
+ ? remotesResult.stdout.trim().split("\n")
130
+ : []
131
+
132
+ // Try to resolve a remote (prefer origin > upstream > first)
133
+ let defaultRemote: string | null = null
134
+ if (remotes.length > 0) {
135
+ if (remotes.includes("origin")) {
136
+ defaultRemote = "origin"
137
+ } else if (remotes.includes("upstream")) {
138
+ defaultRemote = "upstream"
139
+ } else {
140
+ defaultRemote = remotes[0] || null
141
+ }
142
+ }
143
+
144
+ // If we have a remote, try remote branches first (prioritize remote over local)
145
+ if (defaultRemote) {
146
+ // Check for common remote branch names
147
+ for (const branch of ["main", "master"]) {
148
+ const remoteBranch = `${defaultRemote}/${branch}`
149
+ const exists = yield* branchExistsEffect(gitRoot, remoteBranch)
150
+ if (exists) {
151
+ return remoteBranch
152
+ }
153
+ }
154
+ }
155
+
156
+ // Fall back to local branches
157
+ const commonBases = ["main", "master"]
158
+ for (const base of commonBases) {
159
+ const exists = yield* branchExistsEffect(gitRoot, base)
160
+ if (exists) {
161
+ return base
162
+ }
163
+ }
164
+
165
+ return null
166
+ })
167
+
168
+ // Git Service using Effect.Service pattern
169
+ export class GitService extends Effect.Service<GitService>()("GitService", {
170
+ sync: () => ({
171
+ isInsideGitRepo: (path: string) =>
172
+ pipe(
173
+ spawnProcess(["git", "rev-parse", "--is-inside-work-tree"], {
174
+ cwd: path,
175
+ }),
176
+ Effect.map((result) => result.exitCode === 0),
177
+ Effect.mapError(
178
+ () => new GitError({ message: "Failed to check git repo status" }),
179
+ ),
180
+ ),
181
+
182
+ getGitRoot: (path: string) =>
183
+ pipe(
184
+ spawnProcess(["git", "rev-parse", "--show-toplevel"], { cwd: path }),
185
+ Effect.flatMap((result) =>
186
+ result.exitCode === 0
187
+ ? Effect.succeed(result.stdout)
188
+ : Effect.fail(new NotInGitRepoError({ path })),
189
+ ),
190
+ Effect.mapError(() => new NotInGitRepoError({ path })),
191
+ ),
192
+
193
+ isGitRoot: (path: string) =>
194
+ Effect.gen(function* () {
195
+ const absolutePath = yield* Effect.tryPromise({
196
+ try: () => realpath(resolve(path)),
197
+ catch: () => new GitError({ message: "Failed to check if git root" }),
198
+ })
199
+
200
+ const result = yield* pipe(
201
+ spawnProcess(["git", "rev-parse", "--show-toplevel"], {
202
+ cwd: absolutePath,
203
+ }),
204
+ Effect.mapError(
205
+ () => new GitError({ message: "Failed to check if git root" }),
206
+ ),
207
+ )
208
+
209
+ if (result.exitCode !== 0) {
210
+ return false
211
+ }
212
+
213
+ const gitRootReal = yield* Effect.tryPromise({
214
+ try: () => realpath(result.stdout),
215
+ catch: () => new GitError({ message: "Failed to check if git root" }),
216
+ })
217
+
218
+ return gitRootReal === absolutePath
219
+ }),
220
+
221
+ getGitConfig: (key: string, gitRoot: string) =>
222
+ pipe(
223
+ getGitConfigEffect(key, gitRoot),
224
+ Effect.catchAll(() => Effect.succeed(null)),
225
+ ),
226
+
227
+ setGitConfig: (key: string, value: string, gitRoot: string) =>
228
+ setGitConfigEffect(key, value, gitRoot),
229
+
230
+ getCurrentBranch: (gitRoot: string) =>
231
+ runGitCommandOrFail(["git", "branch", "--show-current"], gitRoot),
232
+
233
+ branchExists: (gitRoot: string, branch: string) =>
234
+ pipe(
235
+ branchExistsEffect(gitRoot, branch),
236
+ Effect.mapError(
237
+ () =>
238
+ new GitError({
239
+ message: `Failed to check if branch exists: ${branch}`,
240
+ }),
241
+ ),
242
+ ),
243
+
244
+ createBranch: (
245
+ branchName: string,
246
+ gitRoot: string,
247
+ baseBranch?: string,
248
+ ) => {
249
+ const args = ["git", "checkout", "-b", branchName]
250
+ if (baseBranch) {
251
+ args.push(baseBranch)
252
+ }
253
+
254
+ return runGitCommandVoid(args, gitRoot)
255
+ },
256
+
257
+ checkoutBranch: (gitRoot: string, branch: string) =>
258
+ runGitCommandVoid(["git", "checkout", branch], gitRoot),
259
+
260
+ gitAdd: (files: readonly string[], gitRoot: string) =>
261
+ runGitCommandVoid(["git", "add", ...files], gitRoot),
262
+
263
+ gitCommit: (
264
+ message: string,
265
+ gitRoot: string,
266
+ options?: { readonly noVerify?: boolean },
267
+ ) => {
268
+ const args = ["git", "commit", "-m", message]
269
+ if (options?.noVerify) {
270
+ args.push("--no-verify")
271
+ }
272
+
273
+ return runGitCommandVoid(args, gitRoot)
274
+ },
275
+
276
+ getDefaultRemoteBranch: (gitRoot: string) =>
277
+ Effect.gen(function* () {
278
+ // Get list of remotes
279
+ const remotesResult = yield* runGitCommand(["git", "remote"], gitRoot)
280
+ const remotes =
281
+ remotesResult.exitCode === 0 && remotesResult.stdout.trim()
282
+ ? remotesResult.stdout.trim().split("\n")
283
+ : []
284
+
285
+ if (remotes.length === 0) {
286
+ return null
287
+ }
288
+
289
+ // Try remotes in order of preference: origin > upstream > first
290
+ let remote: string
291
+ if (remotes.includes("origin")) {
292
+ remote = "origin"
293
+ } else if (remotes.includes("upstream")) {
294
+ remote = "upstream"
295
+ } else {
296
+ remote = remotes[0] || ""
297
+ }
298
+
299
+ if (!remote) {
300
+ return null
301
+ }
302
+
303
+ const result = yield* runGitCommand(
304
+ ["git", "rev-parse", "--abbrev-ref", `${remote}/HEAD`],
305
+ gitRoot,
306
+ )
307
+
308
+ return result.exitCode === 0 ? result.stdout : null
309
+ }).pipe(Effect.catchAll(() => Effect.succeed(null))),
310
+
311
+ findMainBranch: (gitRoot: string) =>
312
+ pipe(
313
+ findMainBranchEffect(gitRoot),
314
+ Effect.mapError(
315
+ () => new GitError({ message: "Failed to find main branch" }),
316
+ ),
317
+ ),
318
+
319
+ getSuggestedBaseBranches: (gitRoot: string) =>
320
+ Effect.gen(function* () {
321
+ const suggestions: string[] = []
322
+
323
+ // Get the main branch from config or find it
324
+ const mainBranchFromConfig = yield* getGitConfigEffect(
325
+ "agency.mainBranch",
326
+ gitRoot,
327
+ )
328
+ const mainBranch =
329
+ mainBranchFromConfig || (yield* findMainBranchEffect(gitRoot))
330
+
331
+ if (mainBranch) {
332
+ suggestions.push(mainBranch)
333
+ }
334
+
335
+ // Check for other common base branches
336
+ const commonBases = ["develop", "development", "staging"]
337
+ for (const base of commonBases) {
338
+ const exists = yield* branchExistsEffect(gitRoot, base)
339
+ if (exists && !suggestions.includes(base)) {
340
+ suggestions.push(base)
341
+ }
342
+ }
343
+
344
+ // Get current branch as a suggestion too
345
+ const currentBranch = yield* runGitCommandOrFail(
346
+ ["git", "branch", "--show-current"],
347
+ gitRoot,
348
+ )
349
+ if (currentBranch && !suggestions.includes(currentBranch)) {
350
+ suggestions.push(currentBranch)
351
+ }
352
+
353
+ return suggestions as readonly string[]
354
+ }).pipe(
355
+ Effect.mapError(
356
+ () =>
357
+ new GitError({ message: "Failed to get suggested base branches" }),
358
+ ),
359
+ ),
360
+
361
+ isFeatureBranch: (currentBranch: string, gitRoot: string) =>
362
+ Effect.gen(function* () {
363
+ // Get the main branch from config or find it
364
+ const configBranch = yield* getGitConfigEffect(
365
+ "agency.mainBranch",
366
+ gitRoot,
367
+ )
368
+ const mainBranch =
369
+ configBranch || (yield* findMainBranchEffect(gitRoot))
370
+
371
+ // If we couldn't determine a main branch, assume current is a feature branch
372
+ if (!mainBranch) {
373
+ return true
374
+ }
375
+
376
+ // Save it for future use if we found it and it wasn't in config
377
+ if (!configBranch) {
378
+ yield* setGitConfigEffect("agency.mainBranch", mainBranch, gitRoot)
379
+ }
380
+
381
+ // Handle both local (main) and remote (origin/main) references
382
+ // If mainBranch is "origin/main", we should also consider "main" as the main branch
383
+ const strippedMainBranch =
384
+ mainBranch.match(/^[^/]+\/(.+)$/)?.[1] || mainBranch
385
+
386
+ // Current branch is not a feature branch if it matches either the full or stripped name
387
+ return (
388
+ currentBranch !== mainBranch && currentBranch !== strippedMainBranch
389
+ )
390
+ }).pipe(
391
+ Effect.mapError(
392
+ () => new GitError({ message: "Failed to check if feature branch" }),
393
+ ),
394
+ ),
395
+
396
+ getMainBranchConfig: (gitRoot: string) =>
397
+ pipe(
398
+ getGitConfigEffect("agency.mainBranch", gitRoot),
399
+ Effect.mapError(
400
+ () => new GitError({ message: "Failed to get main branch config" }),
401
+ ),
402
+ ),
403
+
404
+ setMainBranchConfig: (mainBranch: string, gitRoot: string) =>
405
+ setGitConfigEffect("agency.mainBranch", mainBranch, gitRoot),
406
+
407
+ getDefaultBaseBranchConfig: (gitRoot: string) =>
408
+ pipe(
409
+ getGitConfigEffect("agency.baseBranch", gitRoot),
410
+ Effect.mapError(
411
+ () => new GitError({ message: "Failed to get base branch config" }),
412
+ ),
413
+ ),
414
+
415
+ setDefaultBaseBranchConfig: (baseBranch: string, gitRoot: string) =>
416
+ setGitConfigEffect("agency.baseBranch", baseBranch, gitRoot),
417
+
418
+ findDefaultRemote: (gitRoot: string) =>
419
+ pipe(
420
+ findDefaultRemoteEffect(gitRoot),
421
+ Effect.mapError(
422
+ () => new GitError({ message: "Failed to find default remote" }),
423
+ ),
424
+ ),
425
+
426
+ getRemoteConfig: (gitRoot: string) =>
427
+ pipe(
428
+ getGitConfigEffect("agency.remote", gitRoot),
429
+ Effect.mapError(
430
+ () => new GitError({ message: "Failed to get remote config" }),
431
+ ),
432
+ ),
433
+
434
+ setRemoteConfig: (remote: string, gitRoot: string) =>
435
+ setGitConfigEffect("agency.remote", remote, gitRoot),
436
+
437
+ getAllRemotes: (gitRoot: string) =>
438
+ pipe(
439
+ runGitCommand(["git", "remote"], gitRoot),
440
+ Effect.map((result) => {
441
+ if (result.exitCode === 0 && result.stdout.trim()) {
442
+ return result.stdout.trim().split("\n") as readonly string[]
443
+ }
444
+ return [] as readonly string[]
445
+ }),
446
+ Effect.mapError(
447
+ () => new GitError({ message: "Failed to get list of remotes" }),
448
+ ),
449
+ ),
450
+
451
+ remoteExists: (gitRoot: string, remoteName: string) =>
452
+ Effect.gen(function* () {
453
+ const remotes = yield* pipe(
454
+ runGitCommand(["git", "remote"], gitRoot),
455
+ Effect.map((result) => {
456
+ if (result.exitCode === 0 && result.stdout.trim()) {
457
+ return result.stdout.trim().split("\n")
458
+ }
459
+ return []
460
+ }),
461
+ )
462
+ return remotes.includes(remoteName)
463
+ }).pipe(
464
+ Effect.mapError(
465
+ () =>
466
+ new GitError({
467
+ message: `Failed to check if remote ${remoteName} exists`,
468
+ }),
469
+ ),
470
+ ),
471
+
472
+ getRemoteUrl: (gitRoot: string, remoteName: string) =>
473
+ runGitCommandOrFail(
474
+ ["git", "remote", "get-url", remoteName],
475
+ gitRoot,
476
+ ).pipe(
477
+ Effect.mapError(
478
+ () =>
479
+ new GitError({
480
+ message: `Failed to get URL for remote ${remoteName}`,
481
+ }),
482
+ ),
483
+ ),
484
+
485
+ resolveRemote: (gitRoot: string, providedRemote?: string) =>
486
+ Effect.gen(function* () {
487
+ // 1. If explicitly provided, validate and use it
488
+ if (providedRemote) {
489
+ const exists = yield* Effect.gen(function* () {
490
+ const remotes = yield* pipe(
491
+ runGitCommand(["git", "remote"], gitRoot),
492
+ Effect.map((result) => {
493
+ if (result.exitCode === 0 && result.stdout.trim()) {
494
+ return result.stdout.trim().split("\n")
495
+ }
496
+ return []
497
+ }),
498
+ )
499
+ return remotes.includes(providedRemote)
500
+ })
501
+
502
+ if (!exists) {
503
+ return yield* Effect.fail(
504
+ new GitError({
505
+ message: `Remote '${providedRemote}' does not exist`,
506
+ }),
507
+ )
508
+ }
509
+ return providedRemote
510
+ }
511
+
512
+ // 2. Check for saved configuration
513
+ const configRemote = yield* getGitConfigEffect(
514
+ "agency.remote",
515
+ gitRoot,
516
+ ).pipe(Effect.catchAll(() => Effect.succeed(null)))
517
+
518
+ if (configRemote) {
519
+ return configRemote
520
+ }
521
+
522
+ // 3. Auto-detect with smart precedence
523
+ const remotes = yield* pipe(
524
+ runGitCommand(["git", "remote"], gitRoot),
525
+ Effect.map((result) => {
526
+ if (result.exitCode === 0 && result.stdout.trim()) {
527
+ return result.stdout.trim().split("\n")
528
+ }
529
+ return []
530
+ }),
531
+ )
532
+
533
+ if (remotes.length === 0) {
534
+ return yield* Effect.fail(
535
+ new GitError({
536
+ message:
537
+ "No git remotes found. Add a remote with: git remote add <name> <url>",
538
+ }),
539
+ )
540
+ }
541
+
542
+ if (remotes.length === 1) {
543
+ return remotes[0]!
544
+ }
545
+
546
+ // Multiple remotes: prefer origin > upstream > first alphabetically
547
+ if (remotes.includes("origin")) {
548
+ return "origin"
549
+ }
550
+ if (remotes.includes("upstream")) {
551
+ return "upstream"
552
+ }
553
+
554
+ return remotes[0]!
555
+ }).pipe(
556
+ Effect.mapError((error) =>
557
+ error instanceof GitError
558
+ ? error
559
+ : new GitError({
560
+ message: "Failed to resolve remote",
561
+ cause: error,
562
+ }),
563
+ ),
564
+ ),
565
+
566
+ stripRemotePrefix: (branchName: string) => {
567
+ const match = branchName.match(/^[^/]+\/(.+)$/)
568
+ return match?.[1] || branchName
569
+ },
570
+
571
+ hasRemotePrefix: (branchName: string, gitRoot: string) =>
572
+ Effect.gen(function* () {
573
+ const remotes = yield* pipe(
574
+ runGitCommand(["git", "remote"], gitRoot),
575
+ Effect.map((result) => {
576
+ if (result.exitCode === 0 && result.stdout.trim()) {
577
+ return result.stdout.trim().split("\n")
578
+ }
579
+ return []
580
+ }),
581
+ )
582
+ return remotes.some((remote) => branchName.startsWith(`${remote}/`))
583
+ }).pipe(
584
+ Effect.mapError(
585
+ () =>
586
+ new GitError({
587
+ message: `Failed to check if branch has remote prefix: ${branchName}`,
588
+ }),
589
+ ),
590
+ ),
591
+
592
+ getRemoteFromBranch: (branchName: string, gitRoot: string) =>
593
+ Effect.gen(function* () {
594
+ const remotes = yield* pipe(
595
+ runGitCommand(["git", "remote"], gitRoot),
596
+ Effect.map((result) => {
597
+ if (result.exitCode === 0 && result.stdout.trim()) {
598
+ return result.stdout.trim().split("\n")
599
+ }
600
+ return []
601
+ }),
602
+ )
603
+
604
+ for (const remote of remotes) {
605
+ if (branchName.startsWith(`${remote}/`)) {
606
+ return remote
607
+ }
608
+ }
609
+
610
+ return null
611
+ }).pipe(
612
+ Effect.mapError(
613
+ () =>
614
+ new GitError({
615
+ message: `Failed to get remote from branch: ${branchName}`,
616
+ }),
617
+ ),
618
+ ),
619
+
620
+ getDefaultBranchForRemote: (gitRoot: string, remoteName: string) =>
621
+ pipe(
622
+ runGitCommand(
623
+ ["git", "rev-parse", "--abbrev-ref", `${remoteName}/HEAD`],
624
+ gitRoot,
625
+ ),
626
+ Effect.map((result) => (result.exitCode === 0 ? result.stdout : null)),
627
+ Effect.catchAll(() => Effect.succeed(null)),
628
+ ),
629
+
630
+ getMergeBase: (gitRoot: string, branch1: string, branch2: string) =>
631
+ runGitCommandOrFail(["git", "merge-base", branch1, branch2], gitRoot),
632
+
633
+ getMergeBaseForkPoint: (
634
+ gitRoot: string,
635
+ baseBranch: string,
636
+ featureBranch: string,
637
+ ) =>
638
+ runGitCommandOrFail(
639
+ ["git", "merge-base", "--fork-point", baseBranch, featureBranch],
640
+ gitRoot,
641
+ ),
642
+
643
+ deleteBranch: (gitRoot: string, branchName: string, force = false) =>
644
+ runGitCommandVoid(
645
+ ["git", "branch", force ? "-D" : "-d", branchName],
646
+ gitRoot,
647
+ ),
648
+
649
+ unsetGitConfig: (key: string, gitRoot: string) =>
650
+ pipe(
651
+ spawnProcess(["git", "config", "--unset", key], { cwd: gitRoot }),
652
+ Effect.asVoid,
653
+ // Ignore errors - the config might not exist, which is fine
654
+ Effect.mapError(
655
+ () => new GitError({ message: `Failed to unset config ${key}` }),
656
+ ),
657
+ ),
658
+
659
+ checkCommandExists: (command: string) =>
660
+ pipe(
661
+ spawnProcess(["which", command]),
662
+ Effect.map((result) => result.exitCode === 0),
663
+ Effect.mapError(
664
+ () =>
665
+ new GitError({ message: `Failed to check if ${command} exists` }),
666
+ ),
667
+ ),
668
+
669
+ runGitCommand: (
670
+ args: readonly string[],
671
+ gitRoot: string,
672
+ options?: {
673
+ readonly env?: Record<string, string>
674
+ readonly stdin?: string
675
+ readonly captureOutput?: boolean
676
+ },
677
+ ) =>
678
+ pipe(
679
+ spawnProcess(args, {
680
+ cwd: gitRoot,
681
+ stdout: options?.captureOutput ? "pipe" : "inherit",
682
+ stderr: "pipe",
683
+ env: options?.env,
684
+ }),
685
+ Effect.mapError(
686
+ (processError) =>
687
+ new GitCommandError({
688
+ command: processError.command,
689
+ exitCode: processError.exitCode,
690
+ stderr: processError.stderr,
691
+ }),
692
+ ),
693
+ ),
694
+
695
+ fetch: (gitRoot: string, remote?: string, branch?: string) => {
696
+ const args = ["git", "fetch"]
697
+ if (remote) {
698
+ args.push(remote)
699
+ if (branch) {
700
+ args.push(branch)
701
+ }
702
+ }
703
+ return runGitCommandVoid(args, gitRoot)
704
+ },
705
+
706
+ getCommitsBetween: (gitRoot: string, base: string, head: string) =>
707
+ runGitCommandOrFail(
708
+ ["git", "rev-list", "--reverse", `${base}..${head}`],
709
+ gitRoot,
710
+ ),
711
+
712
+ cherryPick: (gitRoot: string, commit: string) =>
713
+ runGitCommandVoid(["git", "cherry-pick", commit], gitRoot),
714
+
715
+ getRemoteTrackingBranch: (gitRoot: string, branch: string) =>
716
+ Effect.gen(function* () {
717
+ const remote = yield* getGitConfigEffect(
718
+ `branch.${branch}.remote`,
719
+ gitRoot,
720
+ ).pipe(Effect.catchAll(() => Effect.succeed(null)))
721
+
722
+ const merge = yield* getGitConfigEffect(
723
+ `branch.${branch}.merge`,
724
+ gitRoot,
725
+ ).pipe(Effect.catchAll(() => Effect.succeed(null)))
726
+
727
+ if (!remote || !merge) {
728
+ return null
729
+ }
730
+
731
+ // Convert refs/heads/branch to remote/branch
732
+ const branchName = merge.replace(/^refs\/heads\//, "")
733
+ return `${remote}/${branchName}`
734
+ }),
735
+
736
+ /**
737
+ * Read file contents from a specific git ref without checking out.
738
+ * Uses `git show <ref>:<path>` to read file contents directly.
739
+ * @param gitRoot - The git repository root
740
+ * @param ref - The git ref (branch name, commit, tag, etc.)
741
+ * @param filePath - Path to the file relative to git root
742
+ * @returns The file contents, or null if the file doesn't exist at that ref
743
+ */
744
+ getFileAtRef: (gitRoot: string, ref: string, filePath: string) =>
745
+ pipe(
746
+ runGitCommand(["git", "show", `${ref}:${filePath}`], gitRoot),
747
+ Effect.map((result) => (result.exitCode === 0 ? result.stdout : null)),
748
+ Effect.catchAll(() => Effect.succeed(null)),
749
+ ),
750
+ }),
751
+ }) {}