@markjaquith/agency 1.11.0 → 2.0.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 (130) hide show
  1. package/README.md +302 -113
  2. package/cli.ts +123 -415
  3. package/index.ts +1 -1
  4. package/package.json +12 -17
  5. package/skills/agency/SKILL.md +258 -0
  6. package/src/cli.test.ts +290 -0
  7. package/src/commands/description.test.ts +103 -0
  8. package/src/commands/epic.test.ts +103 -0
  9. package/src/commands/epic.ts +101 -0
  10. package/src/commands/init.test.ts +42 -290
  11. package/src/commands/init.ts +15 -123
  12. package/src/commands/phase.ts +120 -0
  13. package/src/commands/pr.test.ts +19 -170
  14. package/src/commands/pr.ts +23 -179
  15. package/src/commands/repo.test.ts +80 -0
  16. package/src/commands/repo.ts +83 -0
  17. package/src/commands/status.test.ts +35 -477
  18. package/src/commands/status.ts +24 -213
  19. package/src/commands/task-phase.test.ts +206 -0
  20. package/src/commands/task.ts +83 -1285
  21. package/src/commands/validate.test.ts +91 -0
  22. package/src/commands/validate.ts +50 -0
  23. package/src/commands/work.test.ts +172 -419
  24. package/src/commands/work.ts +47 -166
  25. package/src/services/EpicService.test.ts +71 -0
  26. package/src/services/EpicService.ts +135 -0
  27. package/src/services/FileSystemService.ts +61 -92
  28. package/src/services/PhaseService.ts +323 -0
  29. package/src/services/PullRequestService.test.ts +386 -0
  30. package/src/services/PullRequestService.ts +143 -0
  31. package/src/services/RepositoryService.test.ts +106 -0
  32. package/src/services/RepositoryService.ts +159 -0
  33. package/src/services/TaskPhaseService.test.ts +217 -0
  34. package/src/services/TaskService.ts +192 -0
  35. package/src/services/WorkbaseService.test.ts +446 -0
  36. package/src/services/WorkbaseService.ts +493 -0
  37. package/src/services/WorktreeService.test.ts +599 -0
  38. package/src/services/WorktreeService.ts +357 -0
  39. package/src/test-utils.ts +41 -438
  40. package/src/types.ts +1 -215
  41. package/src/utils/command.ts +1 -5
  42. package/src/utils/effect.test.ts +24 -0
  43. package/src/utils/effect.ts +3 -273
  44. package/src/utils/process.test.ts +0 -15
  45. package/src/utils/process.ts +1 -60
  46. package/src/workbase/frontmatter.test.ts +50 -0
  47. package/src/workbase/frontmatter.ts +77 -0
  48. package/src/workbase/repository-reference.test.ts +25 -0
  49. package/src/workbase/repository-reference.ts +18 -0
  50. package/src/workbase/schemas.test.ts +146 -0
  51. package/src/workbase/schemas.ts +78 -0
  52. package/src/workbase/worktree-command.test.ts +61 -0
  53. package/src/workbase/worktree-command.ts +52 -0
  54. package/src/commands/base.test.ts +0 -198
  55. package/src/commands/base.ts +0 -198
  56. package/src/commands/clean.test.ts +0 -299
  57. package/src/commands/clean.ts +0 -274
  58. package/src/commands/completions.test.ts +0 -51
  59. package/src/commands/completions.ts +0 -308
  60. package/src/commands/emit.integration.test.ts +0 -549
  61. package/src/commands/emit.test.ts +0 -576
  62. package/src/commands/emit.ts +0 -700
  63. package/src/commands/emitted.test.ts +0 -226
  64. package/src/commands/emitted.ts +0 -57
  65. package/src/commands/loop.test.ts +0 -621
  66. package/src/commands/loop.ts +0 -406
  67. package/src/commands/merge.integration.test.ts +0 -195
  68. package/src/commands/merge.test.ts +0 -246
  69. package/src/commands/merge.ts +0 -244
  70. package/src/commands/pull.test.ts +0 -395
  71. package/src/commands/pull.ts +0 -205
  72. package/src/commands/push.test.ts +0 -518
  73. package/src/commands/push.ts +0 -434
  74. package/src/commands/rebase.test.ts +0 -521
  75. package/src/commands/rebase.ts +0 -232
  76. package/src/commands/save.test.ts +0 -247
  77. package/src/commands/save.ts +0 -162
  78. package/src/commands/source.test.ts +0 -195
  79. package/src/commands/source.ts +0 -72
  80. package/src/commands/switch.test.ts +0 -194
  81. package/src/commands/switch.ts +0 -84
  82. package/src/commands/task-branching.test.ts +0 -633
  83. package/src/commands/task-continue.test.ts +0 -311
  84. package/src/commands/task-edit.test.ts +0 -235
  85. package/src/commands/task-main.test.ts +0 -1066
  86. package/src/commands/task-squash.test.ts +0 -266
  87. package/src/commands/tasks.test.ts +0 -391
  88. package/src/commands/tasks.ts +0 -161
  89. package/src/commands/template-delete.test.ts +0 -178
  90. package/src/commands/template-delete.ts +0 -98
  91. package/src/commands/template-list.test.ts +0 -135
  92. package/src/commands/template-list.ts +0 -87
  93. package/src/commands/template-view.test.ts +0 -158
  94. package/src/commands/template-view.ts +0 -86
  95. package/src/commands/template.test.ts +0 -32
  96. package/src/commands/template.ts +0 -96
  97. package/src/commands/use.test.ts +0 -87
  98. package/src/commands/use.ts +0 -97
  99. package/src/constants.ts +0 -10
  100. package/src/errors.ts +0 -17
  101. package/src/schemas.ts +0 -33
  102. package/src/services/AgencyMetadataService.ts +0 -311
  103. package/src/services/ClaudeService.test.ts +0 -184
  104. package/src/services/ClaudeService.ts +0 -91
  105. package/src/services/ConfigService.ts +0 -115
  106. package/src/services/FilterRepoService.ts +0 -164
  107. package/src/services/FormatterService.test.ts +0 -432
  108. package/src/services/FormatterService.ts +0 -219
  109. package/src/services/GitService.ts +0 -1183
  110. package/src/services/MockFilterRepoService.ts +0 -140
  111. package/src/services/OpencodeService.ts +0 -263
  112. package/src/services/PromptService.ts +0 -183
  113. package/src/services/TemplateService.ts +0 -81
  114. package/src/types/native-exec.d.ts +0 -8
  115. package/src/utils/colors.ts +0 -178
  116. package/src/utils/git-path.ts +0 -6
  117. package/src/utils/glob.test.ts +0 -161
  118. package/src/utils/glob.ts +0 -78
  119. package/src/utils/paths.ts +0 -51
  120. package/src/utils/pr-branch.test.ts +0 -376
  121. package/src/utils/pr-branch.ts +0 -508
  122. package/src/utils/spinner.test.ts +0 -45
  123. package/src/utils/spinner.ts +0 -96
  124. package/src/utils/task-parser.test.ts +0 -172
  125. package/src/utils/task-parser.ts +0 -93
  126. package/templates/AGENCY.md +0 -22
  127. package/templates/AGENTS.md +0 -11
  128. package/templates/CLAUDE.md +0 -3
  129. package/templates/TASK.md +0 -5
  130. package/templates/opencode.json +0 -4
@@ -1,1183 +0,0 @@
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
- override get message(): string {
27
- return (
28
- this.stderr ||
29
- `Git command failed with exit code ${this.exitCode}: ${this.command}`
30
- )
31
- }
32
- }
33
-
34
- // Error mapper for git command failures
35
- const mapToGitCommandError = createErrorMapper(GitCommandError)
36
-
37
- // Helper to run git commands with proper error handling
38
- const resolveGitArgs = (args: readonly string[]) => {
39
- if (args[0] === "git") {
40
- // Use absolute path fallback to avoid PATH issues in test environments
41
- return ["/usr/bin/git", ...args.slice(1)] as readonly string[]
42
- }
43
- return args
44
- }
45
-
46
- const runGitCommand = (args: readonly string[], cwd: string) =>
47
- pipe(
48
- spawnProcess(resolveGitArgs(args), { cwd }),
49
- Effect.mapError(
50
- (processError) =>
51
- new GitCommandError({
52
- command: processError.command,
53
- exitCode: processError.exitCode,
54
- stderr: processError.stderr,
55
- }),
56
- ),
57
- )
58
-
59
- // Helper to run git commands and check exit code
60
- const runGitCommandOrFail = (args: readonly string[], cwd: string) =>
61
- pipe(
62
- runGitCommand(args, cwd),
63
- Effect.flatMap(checkExitCodeAndReturnStdout(mapToGitCommandError(args))),
64
- )
65
-
66
- // Helper to run git commands that return void
67
- const runGitCommandVoid = (args: readonly string[], cwd: string) =>
68
- pipe(
69
- runGitCommand(args, cwd),
70
- Effect.flatMap(checkExitCodeAndReturnVoid(mapToGitCommandError(args))),
71
- )
72
-
73
- // Helper to get git config value
74
- const getGitConfigEffect = (key: string, gitRoot: string) =>
75
- pipe(
76
- runGitCommand(["git", "config", "--local", "--get", key], gitRoot),
77
- Effect.map((result) => (result.exitCode === 0 ? result.stdout : null)),
78
- )
79
-
80
- // Helper to set git config value
81
- const setGitConfigEffect = (key: string, value: string, gitRoot: string) =>
82
- pipe(
83
- runGitCommandVoid(["git", "config", "--local", key, value], gitRoot),
84
- Effect.mapError(
85
- (error) =>
86
- new GitError({
87
- message:
88
- error instanceof GitCommandError
89
- ? `Failed to set git config ${key}: ${error.stderr}`
90
- : `Failed to set git config ${key}`,
91
- cause: error,
92
- }),
93
- ),
94
- )
95
-
96
- // Helper to check if branch exists
97
- const branchExistsEffect = (gitRoot: string, branch: string) =>
98
- Effect.gen(function* () {
99
- // Get list of remotes to dynamically check if it's a remote branch
100
- const remotesResult = yield* runGitCommand(["git", "remote"], gitRoot)
101
- const remotes =
102
- remotesResult.exitCode === 0 && remotesResult.stdout.trim()
103
- ? remotesResult.stdout.trim().split("\n")
104
- : []
105
-
106
- // Check if branch name starts with any remote prefix
107
- const hasRemotePrefix = remotes.some((remote) =>
108
- branch.startsWith(`${remote}/`),
109
- )
110
-
111
- const ref = hasRemotePrefix
112
- ? `refs/remotes/${branch}`
113
- : `refs/heads/${branch}`
114
-
115
- const result = yield* runGitCommand(
116
- ["git", "show-ref", "--verify", "--quiet", ref],
117
- gitRoot,
118
- )
119
- return result.exitCode === 0
120
- })
121
-
122
- // Helper to find default remote
123
- const findDefaultRemoteEffect = (gitRoot: string) =>
124
- Effect.gen(function* () {
125
- // Get list of remotes
126
- const result = yield* runGitCommand(["git", "remote"], gitRoot)
127
-
128
- if (result.exitCode === 0 && result.stdout.trim()) {
129
- // Return first remote (usually "origin")
130
- const remotes = result.stdout.trim().split("\n")
131
- return remotes[0] || null
132
- }
133
-
134
- return null
135
- })
136
-
137
- // Helper to find main branch (shared logic)
138
- const findMainBranchEffect = (gitRoot: string) =>
139
- Effect.gen(function* () {
140
- // Get list of remotes
141
- const remotesResult = yield* runGitCommand(["git", "remote"], gitRoot)
142
- const remotes =
143
- remotesResult.exitCode === 0 && remotesResult.stdout.trim()
144
- ? remotesResult.stdout.trim().split("\n")
145
- : []
146
-
147
- // Try to resolve a remote (prefer origin > upstream > first)
148
- let defaultRemote: string | null = null
149
- if (remotes.length > 0) {
150
- if (remotes.includes("origin")) {
151
- defaultRemote = "origin"
152
- } else if (remotes.includes("upstream")) {
153
- defaultRemote = "upstream"
154
- } else {
155
- defaultRemote = remotes[0] || null
156
- }
157
- }
158
-
159
- // If we have a remote, try remote branches first (prioritize remote over local)
160
- if (defaultRemote) {
161
- // Check for common remote branch names
162
- for (const branch of ["main", "master"]) {
163
- const remoteBranch = `${defaultRemote}/${branch}`
164
- const exists = yield* branchExistsEffect(gitRoot, remoteBranch)
165
- if (exists) {
166
- return remoteBranch
167
- }
168
- }
169
- }
170
-
171
- // Fall back to local branches
172
- const commonBases = ["main", "master"]
173
- for (const base of commonBases) {
174
- const exists = yield* branchExistsEffect(gitRoot, base)
175
- if (exists) {
176
- return base
177
- }
178
- }
179
-
180
- return null
181
- })
182
-
183
- // Git Service using Effect.Service pattern
184
- export class GitService extends Effect.Service<GitService>()("GitService", {
185
- sync: () => ({
186
- isInsideGitRepo: (path: string) =>
187
- pipe(
188
- spawnProcess(["git", "rev-parse", "--is-inside-work-tree"], {
189
- cwd: path,
190
- }),
191
- Effect.map((result) => result.exitCode === 0),
192
- Effect.mapError(
193
- () => new GitError({ message: "Failed to check git repo status" }),
194
- ),
195
- ),
196
-
197
- getGitRoot: (path: string) =>
198
- pipe(
199
- spawnProcess(["git", "rev-parse", "--show-toplevel"], { cwd: path }),
200
- Effect.flatMap((result) =>
201
- result.exitCode === 0
202
- ? Effect.succeed(result.stdout)
203
- : Effect.fail(new NotInGitRepoError({ path })),
204
- ),
205
- Effect.mapError(() => new NotInGitRepoError({ path })),
206
- ),
207
-
208
- isGitRoot: (path: string) =>
209
- Effect.gen(function* () {
210
- const absolutePath = yield* Effect.tryPromise({
211
- try: () => realpath(resolve(path)),
212
- catch: () => new GitError({ message: "Failed to check if git root" }),
213
- })
214
-
215
- const result = yield* pipe(
216
- spawnProcess(["git", "rev-parse", "--show-toplevel"], {
217
- cwd: absolutePath,
218
- }),
219
- Effect.mapError(
220
- () => new GitError({ message: "Failed to check if git root" }),
221
- ),
222
- )
223
-
224
- if (result.exitCode !== 0) {
225
- return false
226
- }
227
-
228
- const gitRootReal = yield* Effect.tryPromise({
229
- try: () => realpath(result.stdout),
230
- catch: () => new GitError({ message: "Failed to check if git root" }),
231
- })
232
-
233
- return gitRootReal === absolutePath
234
- }),
235
-
236
- getGitConfig: (key: string, gitRoot: string) =>
237
- pipe(
238
- getGitConfigEffect(key, gitRoot),
239
- Effect.catchAll(() => Effect.succeed(null)),
240
- ),
241
-
242
- setGitConfig: (key: string, value: string, gitRoot: string) =>
243
- setGitConfigEffect(key, value, gitRoot),
244
-
245
- getCurrentBranch: (gitRoot: string) =>
246
- runGitCommandOrFail(["git", "branch", "--show-current"], gitRoot),
247
-
248
- branchExists: (gitRoot: string, branch: string) =>
249
- pipe(
250
- branchExistsEffect(gitRoot, branch),
251
- Effect.mapError(
252
- () =>
253
- new GitError({
254
- message: `Failed to check if branch exists: ${branch}`,
255
- }),
256
- ),
257
- ),
258
-
259
- createBranch: (
260
- branchName: string,
261
- gitRoot: string,
262
- baseBranch?: string,
263
- ) => {
264
- const args = ["git", "checkout", "-q", "--no-track", "-b", branchName]
265
- if (baseBranch) {
266
- args.push(baseBranch)
267
- }
268
-
269
- return runGitCommandVoid(args, gitRoot)
270
- },
271
-
272
- checkoutBranch: (gitRoot: string, branch: string) =>
273
- runGitCommandVoid(["git", "checkout", "-q", branch], gitRoot),
274
-
275
- gitAdd: (files: readonly string[], gitRoot: string) =>
276
- runGitCommandVoid(["git", "add", ...files], gitRoot),
277
-
278
- gitCommit: (
279
- message: string,
280
- gitRoot: string,
281
- options?: { readonly noVerify?: boolean },
282
- ) => {
283
- const args = ["git", "commit", "-m", message]
284
- if (options?.noVerify) {
285
- args.push("--no-verify")
286
- }
287
-
288
- return runGitCommandVoid(args, gitRoot)
289
- },
290
-
291
- getDefaultRemoteBranch: (gitRoot: string) =>
292
- Effect.gen(function* () {
293
- // Get list of remotes
294
- const remotesResult = yield* runGitCommand(["git", "remote"], gitRoot)
295
- const remotes =
296
- remotesResult.exitCode === 0 && remotesResult.stdout.trim()
297
- ? remotesResult.stdout.trim().split("\n")
298
- : []
299
-
300
- if (remotes.length === 0) {
301
- return null
302
- }
303
-
304
- // Try remotes in order of preference: origin > upstream > first
305
- let remote: string
306
- if (remotes.includes("origin")) {
307
- remote = "origin"
308
- } else if (remotes.includes("upstream")) {
309
- remote = "upstream"
310
- } else {
311
- remote = remotes[0] || ""
312
- }
313
-
314
- if (!remote) {
315
- return null
316
- }
317
-
318
- const result = yield* runGitCommand(
319
- ["git", "rev-parse", "--abbrev-ref", `${remote}/HEAD`],
320
- gitRoot,
321
- )
322
-
323
- return result.exitCode === 0 ? result.stdout : null
324
- }).pipe(Effect.catchAll(() => Effect.succeed(null))),
325
-
326
- findMainBranch: (gitRoot: string) =>
327
- pipe(
328
- findMainBranchEffect(gitRoot),
329
- Effect.mapError(
330
- () => new GitError({ message: "Failed to find main branch" }),
331
- ),
332
- ),
333
-
334
- getSuggestedBaseBranches: (gitRoot: string) =>
335
- Effect.gen(function* () {
336
- const suggestions: string[] = []
337
-
338
- // Get the main branch from config or find it
339
- const mainBranchFromConfig = yield* getGitConfigEffect(
340
- "agency.mainBranch",
341
- gitRoot,
342
- )
343
- const mainBranch =
344
- mainBranchFromConfig || (yield* findMainBranchEffect(gitRoot))
345
-
346
- if (mainBranch) {
347
- suggestions.push(mainBranch)
348
- }
349
-
350
- // Check for other common base branches
351
- const commonBases = ["develop", "development", "staging"]
352
- for (const base of commonBases) {
353
- const exists = yield* branchExistsEffect(gitRoot, base)
354
- if (exists && !suggestions.includes(base)) {
355
- suggestions.push(base)
356
- }
357
- }
358
-
359
- // Get current branch as a suggestion too
360
- const currentBranch = yield* runGitCommandOrFail(
361
- ["git", "branch", "--show-current"],
362
- gitRoot,
363
- )
364
- if (currentBranch && !suggestions.includes(currentBranch)) {
365
- suggestions.push(currentBranch)
366
- }
367
-
368
- return suggestions as readonly string[]
369
- }).pipe(
370
- Effect.mapError(
371
- () =>
372
- new GitError({ message: "Failed to get suggested base branches" }),
373
- ),
374
- ),
375
-
376
- isFeatureBranch: (currentBranch: string, gitRoot: string) =>
377
- Effect.gen(function* () {
378
- // Get the main branch from config or find it
379
- const configBranch = yield* getGitConfigEffect(
380
- "agency.mainBranch",
381
- gitRoot,
382
- ).pipe(Effect.catchAll(() => Effect.succeed(null)))
383
- const mainBranch =
384
- configBranch || (yield* findMainBranchEffect(gitRoot))
385
-
386
- // If we couldn't determine a main branch, assume current is a feature branch
387
- if (!mainBranch) {
388
- return true
389
- }
390
-
391
- // Save it for future use if we found it and it wasn't in config
392
- if (!configBranch) {
393
- yield* setGitConfigEffect("agency.mainBranch", mainBranch, gitRoot)
394
- }
395
-
396
- // Handle both local (main) and remote (origin/main) references
397
- // If mainBranch is "origin/main", we should also consider "main" as the main branch
398
- const strippedMainBranch =
399
- mainBranch.match(/^[^/]+\/(.+)$/)?.[1] || mainBranch
400
-
401
- // If currentBranch is empty (detached HEAD), check if HEAD points to main branch
402
- if (!currentBranch) {
403
- const headCommit = yield* runGitCommandOrFail(
404
- ["git", "rev-parse", "HEAD"],
405
- gitRoot,
406
- )
407
- const headSha = headCommit.trim()
408
-
409
- // Check if HEAD matches the configured main branch
410
- const mainCommitResult = yield* spawnProcess(
411
- ["git", "rev-parse", mainBranch],
412
- { cwd: gitRoot },
413
- )
414
- if (
415
- mainCommitResult.exitCode === 0 &&
416
- headSha === mainCommitResult.stdout.trim()
417
- ) {
418
- return false
419
- }
420
-
421
- // If mainBranch doesn't have a remote prefix, also check the remote version
422
- if (!mainBranch.includes("/")) {
423
- const remote = yield* findDefaultRemoteEffect(gitRoot).pipe(
424
- Effect.catchAll(() => Effect.succeed(null)),
425
- )
426
- if (remote) {
427
- const remoteBranch = `${remote}/${mainBranch}`
428
- const remoteCommitResult = yield* spawnProcess(
429
- ["git", "rev-parse", remoteBranch],
430
- { cwd: gitRoot },
431
- )
432
- if (
433
- remoteCommitResult.exitCode === 0 &&
434
- headSha === remoteCommitResult.stdout.trim()
435
- ) {
436
- return false
437
- }
438
- }
439
- }
440
-
441
- return true
442
- }
443
-
444
- // Current branch is not a feature branch if it matches either the full or stripped name
445
- return (
446
- currentBranch !== mainBranch && currentBranch !== strippedMainBranch
447
- )
448
- }).pipe(
449
- Effect.mapError(
450
- () => new GitError({ message: "Failed to check if feature branch" }),
451
- ),
452
- ),
453
-
454
- getMainBranchConfig: (gitRoot: string) =>
455
- pipe(
456
- getGitConfigEffect("agency.mainBranch", gitRoot),
457
- Effect.mapError(
458
- () => new GitError({ message: "Failed to get main branch config" }),
459
- ),
460
- ),
461
-
462
- setMainBranchConfig: (mainBranch: string, gitRoot: string) =>
463
- setGitConfigEffect("agency.mainBranch", mainBranch, gitRoot),
464
-
465
- /**
466
- * Resolves the main branch, preferring remote branches over local ones.
467
- *
468
- * Logic:
469
- * 1. Get the configured main branch (e.g., "main")
470
- * 2. If not configured, use findMainBranch() which already prefers remote
471
- * 3. If configured branch doesn't have a remote prefix, try to find a remote version
472
- * 4. Check if ${remote}/${branch} exists (e.g., "origin/main")
473
- * 5. Use remote version if it exists, fall back to local if not
474
- *
475
- * This ensures we always use the most up-to-date code from the remote
476
- * when creating new branches.
477
- */
478
- resolveMainBranch: (gitRoot: string) =>
479
- Effect.gen(function* () {
480
- // Get the configured main branch
481
- const configuredBranch = yield* getGitConfigEffect(
482
- "agency.mainBranch",
483
- gitRoot,
484
- ).pipe(Effect.catchAll(() => Effect.succeed(null)))
485
-
486
- // If no config, use findMainBranch which already prefers remote
487
- if (!configuredBranch) {
488
- return yield* findMainBranchEffect(gitRoot)
489
- }
490
-
491
- // Check if the configured branch already has a remote prefix (e.g., "origin/main")
492
- const hasRemotePrefix = configuredBranch.includes("/")
493
- if (hasRemotePrefix) {
494
- // Already a remote branch, use as-is
495
- return configuredBranch
496
- }
497
-
498
- // Try to resolve the configured remote
499
- const configuredRemote = yield* getGitConfigEffect(
500
- "agency.remote",
501
- gitRoot,
502
- ).pipe(Effect.catchAll(() => Effect.succeed(null)))
503
-
504
- // If no configured remote, try to find the default remote
505
- const remote =
506
- configuredRemote || (yield* findDefaultRemoteEffect(gitRoot))
507
-
508
- if (remote) {
509
- // Check if the remote version of the branch exists
510
- const remoteBranch = `${remote}/${configuredBranch}`
511
- const remoteExists = yield* branchExistsEffect(gitRoot, remoteBranch)
512
- if (remoteExists) {
513
- return remoteBranch
514
- }
515
- }
516
-
517
- // Fall back to the configured local branch
518
- return configuredBranch
519
- }).pipe(
520
- Effect.mapError(
521
- () => new GitError({ message: "Failed to resolve main branch" }),
522
- ),
523
- ),
524
-
525
- /**
526
- * Gets just the main branch name (e.g., "main" or "master") without any remote prefix.
527
- *
528
- * This is useful when you need to fetch a specific branch from a remote,
529
- * as `git fetch origin main` requires just the branch name, not `origin/main`.
530
- */
531
- getMainBranchName: (gitRoot: string) =>
532
- Effect.gen(function* () {
533
- // Get the configured main branch
534
- const configuredBranch = yield* getGitConfigEffect(
535
- "agency.mainBranch",
536
- gitRoot,
537
- ).pipe(Effect.catchAll(() => Effect.succeed(null)))
538
-
539
- // If configured, strip any remote prefix and return
540
- if (configuredBranch) {
541
- // Strip remote prefix if present (e.g., "origin/main" -> "main")
542
- return (
543
- configuredBranch.match(/^[^/]+\/(.+)$/)?.[1] || configuredBranch
544
- )
545
- }
546
-
547
- // Otherwise, find the main branch by checking what exists
548
- // We need to check for common branch names that exist (local or remote)
549
- const remotesResult = yield* runGitCommand(["git", "remote"], gitRoot)
550
- const remotes =
551
- remotesResult.exitCode === 0 && remotesResult.stdout.trim()
552
- ? remotesResult.stdout.trim().split("\n")
553
- : []
554
-
555
- // Determine the default remote
556
- let defaultRemote: string | null = null
557
- if (remotes.length > 0) {
558
- if (remotes.includes("origin")) {
559
- defaultRemote = "origin"
560
- } else if (remotes.includes("upstream")) {
561
- defaultRemote = "upstream"
562
- } else {
563
- defaultRemote = remotes[0] || null
564
- }
565
- }
566
-
567
- // Check for common branch names (either remote or local)
568
- for (const branchName of ["main", "master"]) {
569
- // Check remote version first
570
- if (defaultRemote) {
571
- const remoteBranch = `${defaultRemote}/${branchName}`
572
- const exists = yield* branchExistsEffect(gitRoot, remoteBranch)
573
- if (exists) {
574
- return branchName
575
- }
576
- }
577
- // Check local version
578
- const localExists = yield* branchExistsEffect(gitRoot, branchName)
579
- if (localExists) {
580
- return branchName
581
- }
582
- }
583
-
584
- return null
585
- }).pipe(
586
- Effect.mapError(
587
- () => new GitError({ message: "Failed to get main branch name" }),
588
- ),
589
- ),
590
-
591
- getDefaultBaseBranchConfig: (gitRoot: string) =>
592
- pipe(
593
- getGitConfigEffect("agency.baseBranch", gitRoot),
594
- Effect.mapError(
595
- () => new GitError({ message: "Failed to get base branch config" }),
596
- ),
597
- ),
598
-
599
- setDefaultBaseBranchConfig: (baseBranch: string, gitRoot: string) =>
600
- setGitConfigEffect("agency.baseBranch", baseBranch, gitRoot),
601
-
602
- findDefaultRemote: (gitRoot: string) =>
603
- pipe(
604
- findDefaultRemoteEffect(gitRoot),
605
- Effect.mapError(
606
- () => new GitError({ message: "Failed to find default remote" }),
607
- ),
608
- ),
609
-
610
- getRemoteConfig: (gitRoot: string) =>
611
- pipe(
612
- getGitConfigEffect("agency.remote", gitRoot),
613
- Effect.mapError(
614
- () => new GitError({ message: "Failed to get remote config" }),
615
- ),
616
- ),
617
-
618
- setRemoteConfig: (remote: string, gitRoot: string) =>
619
- setGitConfigEffect("agency.remote", remote, gitRoot),
620
-
621
- getAllRemotes: (gitRoot: string) =>
622
- pipe(
623
- runGitCommand(["git", "remote"], gitRoot),
624
- Effect.map((result) => {
625
- if (result.exitCode === 0 && result.stdout.trim()) {
626
- return result.stdout.trim().split("\n") as readonly string[]
627
- }
628
- return [] as readonly string[]
629
- }),
630
- Effect.mapError(
631
- () => new GitError({ message: "Failed to get list of remotes" }),
632
- ),
633
- ),
634
-
635
- remoteExists: (gitRoot: string, remoteName: string) =>
636
- Effect.gen(function* () {
637
- const remotes = yield* pipe(
638
- runGitCommand(["git", "remote"], gitRoot),
639
- Effect.map((result) => {
640
- if (result.exitCode === 0 && result.stdout.trim()) {
641
- return result.stdout.trim().split("\n")
642
- }
643
- return []
644
- }),
645
- )
646
- return remotes.includes(remoteName)
647
- }).pipe(
648
- Effect.mapError(
649
- () =>
650
- new GitError({
651
- message: `Failed to check if remote ${remoteName} exists`,
652
- }),
653
- ),
654
- ),
655
-
656
- getRemoteUrl: (gitRoot: string, remoteName: string) =>
657
- runGitCommandOrFail(
658
- ["git", "remote", "get-url", remoteName],
659
- gitRoot,
660
- ).pipe(
661
- Effect.mapError(
662
- () =>
663
- new GitError({
664
- message: `Failed to get URL for remote ${remoteName}`,
665
- }),
666
- ),
667
- ),
668
-
669
- resolveRemote: (gitRoot: string, providedRemote?: string) =>
670
- Effect.gen(function* () {
671
- // 1. If explicitly provided, validate and use it
672
- if (providedRemote) {
673
- const exists = yield* Effect.gen(function* () {
674
- const remotes = yield* pipe(
675
- runGitCommand(["git", "remote"], gitRoot),
676
- Effect.map((result) => {
677
- if (result.exitCode === 0 && result.stdout.trim()) {
678
- return result.stdout.trim().split("\n")
679
- }
680
- return []
681
- }),
682
- )
683
- return remotes.includes(providedRemote)
684
- })
685
-
686
- if (!exists) {
687
- return yield* Effect.fail(
688
- new GitError({
689
- message: `Remote '${providedRemote}' does not exist`,
690
- }),
691
- )
692
- }
693
- return providedRemote
694
- }
695
-
696
- // 2. Check for saved configuration
697
- const configRemote = yield* getGitConfigEffect(
698
- "agency.remote",
699
- gitRoot,
700
- ).pipe(Effect.catchAll(() => Effect.succeed(null)))
701
-
702
- if (configRemote) {
703
- return configRemote
704
- }
705
-
706
- // 3. Auto-detect with smart precedence
707
- const remotes = yield* pipe(
708
- runGitCommand(["git", "remote"], gitRoot),
709
- Effect.map((result) => {
710
- if (result.exitCode === 0 && result.stdout.trim()) {
711
- return result.stdout.trim().split("\n")
712
- }
713
- return []
714
- }),
715
- )
716
-
717
- if (remotes.length === 0) {
718
- return yield* Effect.fail(
719
- new GitError({
720
- message:
721
- "No git remotes found. Add a remote with: git remote add <name> <url>",
722
- }),
723
- )
724
- }
725
-
726
- if (remotes.length === 1) {
727
- return remotes[0]!
728
- }
729
-
730
- // Multiple remotes: prefer origin > upstream > first alphabetically
731
- if (remotes.includes("origin")) {
732
- return "origin"
733
- }
734
- if (remotes.includes("upstream")) {
735
- return "upstream"
736
- }
737
-
738
- return remotes[0]!
739
- }).pipe(
740
- Effect.mapError((error) =>
741
- error instanceof GitError
742
- ? error
743
- : new GitError({
744
- message: "Failed to resolve remote",
745
- cause: error,
746
- }),
747
- ),
748
- ),
749
-
750
- stripRemotePrefix: (branchName: string) => {
751
- const match = branchName.match(/^[^/]+\/(.+)$/)
752
- return match?.[1] || branchName
753
- },
754
-
755
- hasRemotePrefix: (branchName: string, gitRoot: string) =>
756
- Effect.gen(function* () {
757
- const remotes = yield* pipe(
758
- runGitCommand(["git", "remote"], gitRoot),
759
- Effect.map((result) => {
760
- if (result.exitCode === 0 && result.stdout.trim()) {
761
- return result.stdout.trim().split("\n")
762
- }
763
- return []
764
- }),
765
- )
766
- return remotes.some((remote) => branchName.startsWith(`${remote}/`))
767
- }).pipe(
768
- Effect.mapError(
769
- () =>
770
- new GitError({
771
- message: `Failed to check if branch has remote prefix: ${branchName}`,
772
- }),
773
- ),
774
- ),
775
-
776
- getRemoteFromBranch: (branchName: string, gitRoot: string) =>
777
- Effect.gen(function* () {
778
- const remotes = yield* pipe(
779
- runGitCommand(["git", "remote"], gitRoot),
780
- Effect.map((result) => {
781
- if (result.exitCode === 0 && result.stdout.trim()) {
782
- return result.stdout.trim().split("\n")
783
- }
784
- return []
785
- }),
786
- )
787
-
788
- for (const remote of remotes) {
789
- if (branchName.startsWith(`${remote}/`)) {
790
- return remote
791
- }
792
- }
793
-
794
- return null
795
- }).pipe(
796
- Effect.mapError(
797
- () =>
798
- new GitError({
799
- message: `Failed to get remote from branch: ${branchName}`,
800
- }),
801
- ),
802
- ),
803
-
804
- getDefaultBranchForRemote: (gitRoot: string, remoteName: string) =>
805
- pipe(
806
- runGitCommand(
807
- ["git", "rev-parse", "--abbrev-ref", `${remoteName}/HEAD`],
808
- gitRoot,
809
- ),
810
- Effect.map((result) => (result.exitCode === 0 ? result.stdout : null)),
811
- Effect.catchAll(() => Effect.succeed(null)),
812
- ),
813
-
814
- getMergeBase: (gitRoot: string, branch1: string, branch2: string) =>
815
- runGitCommandOrFail(["git", "merge-base", branch1, branch2], gitRoot),
816
-
817
- getMergeBaseForkPoint: (
818
- gitRoot: string,
819
- baseBranch: string,
820
- featureBranch: string,
821
- ) =>
822
- runGitCommandOrFail(
823
- ["git", "merge-base", "--fork-point", baseBranch, featureBranch],
824
- gitRoot,
825
- ),
826
-
827
- deleteBranch: (gitRoot: string, branchName: string, force = false) =>
828
- runGitCommandVoid(
829
- ["git", "branch", force ? "-D" : "-d", branchName],
830
- gitRoot,
831
- ),
832
-
833
- unsetGitConfig: (key: string, gitRoot: string) =>
834
- pipe(
835
- spawnProcess(["git", "config", "--unset", key], { cwd: gitRoot }),
836
- Effect.asVoid,
837
- // Ignore errors - the config might not exist, which is fine
838
- Effect.mapError(
839
- () => new GitError({ message: `Failed to unset config ${key}` }),
840
- ),
841
- ),
842
-
843
- checkCommandExists: (command: string) =>
844
- pipe(
845
- spawnProcess(["which", command]),
846
- Effect.map((result) => result.exitCode === 0),
847
- Effect.mapError(
848
- () =>
849
- new GitError({ message: `Failed to check if ${command} exists` }),
850
- ),
851
- ),
852
-
853
- runGitCommand: (
854
- args: readonly string[],
855
- gitRoot: string,
856
- options?: {
857
- readonly env?: Record<string, string>
858
- readonly stdin?: string
859
- readonly captureOutput?: boolean
860
- },
861
- ) =>
862
- pipe(
863
- spawnProcess(args, {
864
- cwd: gitRoot,
865
- stdout: options?.captureOutput ? "pipe" : "inherit",
866
- stderr: "pipe",
867
- env: options?.env,
868
- }),
869
- Effect.mapError(
870
- (processError) =>
871
- new GitCommandError({
872
- command: processError.command,
873
- exitCode: processError.exitCode,
874
- stderr: processError.stderr,
875
- }),
876
- ),
877
- ),
878
-
879
- fetch: (gitRoot: string, remote?: string, branch?: string) => {
880
- const args = ["git", "fetch"]
881
- if (remote) {
882
- args.push(remote)
883
- if (branch) {
884
- args.push(branch)
885
- }
886
- }
887
- return runGitCommandVoid(args, gitRoot)
888
- },
889
-
890
- getCommitsBetween: (gitRoot: string, base: string, head: string) =>
891
- runGitCommandOrFail(
892
- ["git", "rev-list", "--reverse", `${base}..${head}`],
893
- gitRoot,
894
- ),
895
-
896
- cherryPick: (gitRoot: string, commit: string) =>
897
- runGitCommandVoid(["git", "cherry-pick", commit], gitRoot),
898
-
899
- getRemoteTrackingBranch: (gitRoot: string, branch: string) =>
900
- Effect.gen(function* () {
901
- const remote = yield* getGitConfigEffect(
902
- `branch.${branch}.remote`,
903
- gitRoot,
904
- ).pipe(Effect.catchAll(() => Effect.succeed(null)))
905
-
906
- const merge = yield* getGitConfigEffect(
907
- `branch.${branch}.merge`,
908
- gitRoot,
909
- ).pipe(Effect.catchAll(() => Effect.succeed(null)))
910
-
911
- if (!remote || !merge) {
912
- return null
913
- }
914
-
915
- // Convert refs/heads/branch to remote/branch
916
- const branchName = merge.replace(/^refs\/heads\//, "")
917
- return `${remote}/${branchName}`
918
- }),
919
-
920
- /**
921
- * Read file contents from a specific git ref without checking out.
922
- * Uses `git show <ref>:<path>` to read file contents directly.
923
- * @param gitRoot - The git repository root
924
- * @param ref - The git ref (branch name, commit, tag, etc.)
925
- * @param filePath - Path to the file relative to git root
926
- * @returns The file contents, or null if the file doesn't exist at that ref
927
- */
928
- getFileAtRef: (gitRoot: string, ref: string, filePath: string) =>
929
- pipe(
930
- runGitCommand(["git", "show", `${ref}:${filePath}`], gitRoot),
931
- Effect.map((result) => (result.exitCode === 0 ? result.stdout : null)),
932
- Effect.catchAll(() => Effect.succeed(null)),
933
- ),
934
-
935
- /**
936
- * Check if a file has uncommitted changes (staged or unstaged).
937
- * Uses `git diff HEAD` to check for any changes to the file.
938
- * @param gitRoot - The git repository root
939
- * @param filePath - Path to the file relative to git root
940
- * @returns true if the file has changes, false otherwise
941
- */
942
- hasUncommittedChanges: (gitRoot: string, filePath: string) =>
943
- pipe(
944
- runGitCommand(["git", "diff", "HEAD", "--", filePath], gitRoot),
945
- Effect.map(
946
- (result) => result.exitCode === 0 && result.stdout.length > 0,
947
- ),
948
- Effect.catchAll(() => Effect.succeed(false)),
949
- ),
950
-
951
- /**
952
- * Get all local branch names.
953
- * @param gitRoot - The git repository root
954
- * @returns Array of local branch names
955
- */
956
- getAllLocalBranches: (gitRoot: string) =>
957
- pipe(
958
- runGitCommand(["git", "branch", "--format=%(refname:short)"], gitRoot),
959
- Effect.map((result) => {
960
- if (result.exitCode === 0 && result.stdout.trim()) {
961
- return result.stdout.trim().split("\n") as readonly string[]
962
- }
963
- return [] as readonly string[]
964
- }),
965
- Effect.mapError(
966
- () => new GitError({ message: "Failed to get local branches" }),
967
- ),
968
- ),
969
-
970
- /**
971
- * Get local branch names matching a prefix.
972
- * @param gitRoot - The git repository root
973
- * @param prefix - The prefix to match (e.g., "agency--")
974
- * @returns Array of matching local branch names
975
- */
976
- getBranchesByPrefix: (gitRoot: string, prefix: string) =>
977
- pipe(
978
- runGitCommand(
979
- [
980
- "git",
981
- "branch",
982
- "--list",
983
- `${prefix}*`,
984
- "--format=%(refname:short)",
985
- ],
986
- gitRoot,
987
- ),
988
- Effect.map((result) => {
989
- if (result.exitCode === 0 && result.stdout.trim()) {
990
- return result.stdout.trim().split("\n") as readonly string[]
991
- }
992
- return [] as readonly string[]
993
- }),
994
- Effect.mapError(
995
- () =>
996
- new GitError({
997
- message: `Failed to get branches with prefix "${prefix}"`,
998
- }),
999
- ),
1000
- ),
1001
-
1002
- /**
1003
- * Get branches that have been merged into a target branch.
1004
- * @param gitRoot - The git repository root
1005
- * @param targetBranch - The branch to check merges against
1006
- * @returns Array of merged branch names
1007
- */
1008
- getMergedBranches: (gitRoot: string, targetBranch: string) =>
1009
- pipe(
1010
- runGitCommand(
1011
- [
1012
- "git",
1013
- "branch",
1014
- "--merged",
1015
- targetBranch,
1016
- "--format=%(refname:short)",
1017
- ],
1018
- gitRoot,
1019
- ),
1020
- Effect.map((result) => {
1021
- if (result.exitCode === 0 && result.stdout.trim()) {
1022
- return result.stdout.trim().split("\n") as readonly string[]
1023
- }
1024
- return [] as readonly string[]
1025
- }),
1026
- Effect.mapError(
1027
- () =>
1028
- new GitError({
1029
- message: `Failed to get branches merged into ${targetBranch}`,
1030
- }),
1031
- ),
1032
- ),
1033
-
1034
- /**
1035
- * Push a branch to a remote.
1036
- * @param gitRoot - The git repository root
1037
- * @param remote - Remote name (e.g., "origin")
1038
- * @param branch - Branch name to push
1039
- * @param options - Push options
1040
- * @returns Object with exitCode, stdout, stderr
1041
- */
1042
- push: (
1043
- gitRoot: string,
1044
- remote: string,
1045
- branch: string,
1046
- options?: {
1047
- readonly setUpstream?: boolean
1048
- readonly force?: boolean
1049
- readonly noVerify?: boolean
1050
- },
1051
- ) => {
1052
- const args = ["git", "push"]
1053
- if (options?.setUpstream) {
1054
- args.push("-u")
1055
- }
1056
- if (options?.force) {
1057
- args.push("--force")
1058
- }
1059
- if (options?.noVerify) {
1060
- args.push("--no-verify")
1061
- }
1062
- args.push(remote, branch)
1063
-
1064
- return pipe(
1065
- runGitCommand(args, gitRoot),
1066
- Effect.mapError(
1067
- (error) =>
1068
- new GitError({
1069
- message: `Failed to push ${branch} to ${remote}`,
1070
- cause: error,
1071
- }),
1072
- ),
1073
- )
1074
- },
1075
-
1076
- /**
1077
- * Merge a branch into the current branch.
1078
- * @param gitRoot - The git repository root
1079
- * @param branch - Branch to merge
1080
- * @param options - Merge options
1081
- * @returns Object with exitCode, stdout, stderr
1082
- */
1083
- merge: (
1084
- gitRoot: string,
1085
- branch: string,
1086
- options?: {
1087
- readonly squash?: boolean
1088
- readonly noCommit?: boolean
1089
- readonly message?: string
1090
- },
1091
- ) => {
1092
- const args = ["git", "merge"]
1093
- if (options?.squash) {
1094
- args.push("--squash")
1095
- }
1096
- if (options?.noCommit) {
1097
- args.push("--no-commit")
1098
- }
1099
- if (options?.message) {
1100
- args.push("-m", options.message)
1101
- }
1102
- args.push(branch)
1103
-
1104
- return pipe(
1105
- runGitCommand(args, gitRoot),
1106
- Effect.mapError(
1107
- (error) =>
1108
- new GitError({
1109
- message: `Failed to merge ${branch}`,
1110
- cause: error,
1111
- }),
1112
- ),
1113
- )
1114
- },
1115
-
1116
- /**
1117
- * Rebase current branch onto a base branch.
1118
- * @param gitRoot - The git repository root
1119
- * @param baseBranch - Branch to rebase onto
1120
- * @returns Object with exitCode, stdout, stderr
1121
- */
1122
- rebase: (gitRoot: string, baseBranch: string) =>
1123
- pipe(
1124
- runGitCommand(["git", "rebase", baseBranch], gitRoot),
1125
- Effect.mapError(
1126
- (error) =>
1127
- new GitError({
1128
- message: `Failed to rebase onto ${baseBranch}`,
1129
- cause: error,
1130
- }),
1131
- ),
1132
- ),
1133
-
1134
- /**
1135
- * Get the working tree status (porcelain format).
1136
- * @param gitRoot - The git repository root
1137
- * @returns Status output string
1138
- */
1139
- getStatus: (gitRoot: string) =>
1140
- pipe(
1141
- runGitCommand(["git", "status", "--porcelain"], gitRoot),
1142
- Effect.map((result) => result.stdout),
1143
- Effect.mapError(
1144
- () => new GitError({ message: "Failed to get git status" }),
1145
- ),
1146
- ),
1147
-
1148
- /**
1149
- * Check if one commit is an ancestor of another.
1150
- * @param gitRoot - The git repository root
1151
- * @param potentialAncestor - The commit that might be an ancestor
1152
- * @param commit - The commit to check against
1153
- * @returns true if potentialAncestor is an ancestor of commit
1154
- */
1155
- isAncestor: (gitRoot: string, potentialAncestor: string, commit: string) =>
1156
- pipe(
1157
- runGitCommand(
1158
- ["git", "merge-base", "--is-ancestor", potentialAncestor, commit],
1159
- gitRoot,
1160
- ),
1161
- Effect.map((result) => result.exitCode === 0),
1162
- Effect.catchAll(() => Effect.succeed(false)),
1163
- ),
1164
-
1165
- /**
1166
- * Create or reset a branch to point to a specific commit/branch.
1167
- * Uses `git checkout -B` which creates the branch if it doesn't exist
1168
- * or resets it if it does.
1169
- * @param gitRoot - The git repository root
1170
- * @param branchName - Name of the branch to create/reset
1171
- * @param startPoint - Commit or branch to start from
1172
- */
1173
- createOrResetBranch: (
1174
- gitRoot: string,
1175
- branchName: string,
1176
- startPoint: string,
1177
- ) =>
1178
- runGitCommandVoid(
1179
- ["git", "checkout", "-q", "-B", branchName, startPoint],
1180
- gitRoot,
1181
- ),
1182
- }),
1183
- }) {}