@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,700 +0,0 @@
1
- import { Effect } from "effect"
2
- import type { BaseCommandOptions } from "../utils/command"
3
- import { GitService } from "../services/GitService"
4
- import { ConfigService } from "../services/ConfigService"
5
- import { FileSystemService } from "../services/FileSystemService"
6
- import { FilterRepoService } from "../services/FilterRepoService"
7
- import {
8
- makeEmitBranchName,
9
- makeSourceBranchName,
10
- extractCleanBranch,
11
- extractCleanFromEmit,
12
- } from "../utils/pr-branch"
13
- import {
14
- getFilesToFilter,
15
- readAgencyMetadata,
16
- writeAgencyMetadata,
17
- } from "../types"
18
- import highlight, { done } from "../utils/colors"
19
- import {
20
- createLoggers,
21
- ensureGitRepo,
22
- resolveBaseBranch,
23
- withBranchProtection,
24
- } from "../utils/effect"
25
- import { resolveGitInternalPath } from "../utils/git-path"
26
- import { withSpinner } from "../utils/spinner"
27
- import { AGENCY_REMOVE_COMMIT } from "../constants"
28
-
29
- interface EmitOptions extends BaseCommandOptions {
30
- emit?: string
31
- branch?: string // Deprecated: use emit instead
32
- baseBranch?: string
33
- force?: boolean
34
- /** Skip the git-filter-repo step (for testing) */
35
- skipFilter?: boolean
36
- }
37
-
38
- export const emit = (options: EmitOptions = {}) =>
39
- Effect.gen(function* () {
40
- const gitRoot = yield* ensureGitRepo()
41
-
42
- // Wrap the entire emit operation with branch protection
43
- // This ensures we return to the original branch on Ctrl-C interrupt
44
- yield* withBranchProtection(gitRoot, emitCore(gitRoot, options))
45
- })
46
-
47
- export const emitCore = (gitRoot: string, options: EmitOptions) =>
48
- Effect.gen(function* () {
49
- const { force = false, verbose = false, skipFilter = false } = options
50
- const { log, verboseLog } = createLoggers(options)
51
-
52
- const git = yield* GitService
53
- const configService = yield* ConfigService
54
- const fs = yield* FileSystemService
55
- const filterRepo = yield* FilterRepoService
56
-
57
- // Check for uncommitted changes
58
- const statusOutput = yield* git.getStatus(gitRoot)
59
-
60
- if (statusOutput && statusOutput.trim().length > 0) {
61
- return yield* Effect.fail(
62
- new Error(
63
- `You have uncommitted changes. Please commit or stash them before emitting.\n` +
64
- `The emit command performs internal checkouts that would destroy uncommitted work.\n` +
65
- `Run 'git status' to see the changes.`,
66
- ),
67
- )
68
- }
69
-
70
- verboseLog(`Working directory is clean`)
71
-
72
- // Check if git-filter-repo is installed
73
- const hasFilterRepo = yield* filterRepo.isInstalled()
74
- if (!hasFilterRepo) {
75
- const isMac = process.platform === "darwin"
76
- const installInstructions = isMac
77
- ? "Please install it via Homebrew: brew install git-filter-repo"
78
- : "Please install it using your package manager. See: https://github.com/newren/git-filter-repo/blob/main/INSTALL.md"
79
- return yield* Effect.fail(
80
- new Error(`git-filter-repo is not installed. ${installInstructions}`),
81
- )
82
- }
83
-
84
- // Load config
85
- const config = yield* configService.loadConfig()
86
-
87
- // Get current branch
88
- let currentBranch = yield* git.getCurrentBranch(gitRoot)
89
-
90
- // Check if current branch is an emit branch (doesn't match source pattern)
91
- // If so, try to find and switch to the corresponding source branch
92
- const possibleCleanBranch = extractCleanFromEmit(
93
- currentBranch,
94
- config.emitBranch,
95
- )
96
- if (possibleCleanBranch) {
97
- // This looks like an emit branch, try to find the source branch
98
- const possibleSourceBranch = makeSourceBranchName(
99
- possibleCleanBranch,
100
- config.sourceBranchPattern,
101
- )
102
- const sourceExists = yield* git.branchExists(
103
- gitRoot,
104
- possibleSourceBranch,
105
- )
106
- if (sourceExists) {
107
- // Switch to the source branch and continue
108
- verboseLog(
109
- `Currently on emit branch ${highlight.branch(currentBranch)}, switching to source branch ${highlight.branch(possibleSourceBranch)}`,
110
- )
111
- yield* git.checkoutBranch(gitRoot, possibleSourceBranch)
112
- currentBranch = possibleSourceBranch
113
- }
114
- }
115
-
116
- // First, check if agency.json already has emitBranch set (source of truth)
117
- const metadata = yield* Effect.tryPromise({
118
- try: () => readAgencyMetadata(gitRoot),
119
- catch: () => null,
120
- })
121
-
122
- let emitBranchName: string
123
-
124
- // Support both --emit (new) and --branch (deprecated)
125
- const explicitBranchName = options.emit || options.branch
126
-
127
- if (explicitBranchName) {
128
- // Explicit branch name provided via CLI
129
- emitBranchName = explicitBranchName
130
- } else if (metadata?.emitBranch) {
131
- // Use emitBranch from agency.json (source of truth)
132
- emitBranchName = metadata.emitBranch
133
- verboseLog(
134
- `Using emit branch from agency.json: ${highlight.branch(emitBranchName)}`,
135
- )
136
- } else {
137
- // Compute emit branch name from patterns
138
- // Extract clean branch from source branch pattern
139
- // If the branch matches the source pattern, extract the clean name
140
- // Otherwise, treat the current branch as a "legacy" branch (the clean name itself)
141
- const cleanBranch =
142
- extractCleanBranch(currentBranch, config.sourceBranchPattern) ||
143
- currentBranch
144
- emitBranchName = makeEmitBranchName(cleanBranch, config.emitBranch)
145
- }
146
-
147
- // Check and update agency.json with emitBranch if needed
148
- yield* ensureEmitBranchInMetadata(gitRoot, currentBranch, emitBranchName)
149
-
150
- // Find the base branch this was created from
151
- const baseBranch = yield* resolveBaseBranch(gitRoot, options.baseBranch)
152
-
153
- verboseLog(`Using base branch: ${highlight.branch(baseBranch)}`)
154
-
155
- // Get the fork-point (where the branch actually forked from, using reflog)
156
- // This is more accurate than merge-base because it accounts for rebases
157
- const forkPoint = yield* findBestForkPoint(
158
- gitRoot,
159
- currentBranch,
160
- baseBranch,
161
- options,
162
- )
163
-
164
- verboseLog(`Branch forked at commit: ${highlight.commit(forkPoint)}`)
165
-
166
- // Create or reset emit branch from current branch
167
- let branchExisted = false
168
- const createBranch = Effect.gen(function* () {
169
- const existed = yield* createOrResetBranchEffect(
170
- gitRoot,
171
- currentBranch,
172
- emitBranchName,
173
- )
174
- branchExisted = existed
175
-
176
- yield* configureEmitBranchTracking(gitRoot, emitBranchName, verboseLog)
177
- })
178
-
179
- yield* withSpinner(createBranch, {
180
- text: `Creating emit branch ${highlight.branch(emitBranchName)}`,
181
- successText: `${branchExisted ? "Recreated" : "Created"} emit branch ${highlight.branch(emitBranchName)}`,
182
- enabled: !options.silent && !verbose,
183
- })
184
-
185
- // Skip filtering if requested (for testing)
186
- if (skipFilter) {
187
- verboseLog("Skipping git-filter-repo (skipFilter=true)")
188
- // Just switch back to source branch
189
- yield* git.checkoutBranch(gitRoot, currentBranch)
190
- log(done(`Emitted ${highlight.branch(emitBranchName)} (filter skipped)`))
191
- return
192
- }
193
-
194
- verboseLog(
195
- `Filtering backpack files from commits in range: ${highlight.commit(forkPoint.substring(0, 8))}..${highlight.branch(emitBranchName)}`,
196
- )
197
- verboseLog(
198
- `Files will revert to their state at fork-point (base branch: ${highlight.branch(baseBranch)})`,
199
- )
200
-
201
- // Filter backpack files
202
- const filterOperation = Effect.gen(function* () {
203
- // Resolve the actual git-filter-repo state directory.
204
- // In linked worktrees this lives under the worktree git dir, not repo/.git.
205
- const filterRepoDir = yield* getFilterRepoStateDir(gitRoot)
206
- verboseLog(
207
- `Cleaning git-filter-repo state in ${highlight.file(filterRepoDir)}`,
208
- )
209
- yield* fs.deleteDirectory(filterRepoDir)
210
- verboseLog("Cleaned up previous git-filter-repo state")
211
-
212
- // Get files to filter from agency.json metadata
213
- const filesToFilter = yield* Effect.tryPromise({
214
- try: () => getFilesToFilter(gitRoot),
215
- catch: (error) => new Error(`Failed to get files to filter: ${error}`),
216
- })
217
- verboseLog(`Files to filter: ${filesToFilter.join(", ")}`)
218
-
219
- // Run git-filter-repo with:
220
- // 1. Path filtering to remove backpack files
221
- // 2. Commit callback to drop file changes from commits marked with AGENCY_REMOVE_COMMIT
222
- // (clearing file_changes makes the commit empty and it gets pruned, while preserving tree state)
223
- const filterRepoArgs = [
224
- ...filesToFilter.flatMap((f) => ["--path", f]),
225
- "--invert-paths",
226
- "--commit-callback",
227
- // Clear file changes from commits with AGENCY_REMOVE_COMMIT marker
228
- // This makes the commit empty (which gets pruned) while preserving the tree state
229
- `if b"${AGENCY_REMOVE_COMMIT}" in commit.message: commit.file_changes = []`,
230
- "--force",
231
- "--refs",
232
- `${forkPoint}..${emitBranchName}`,
233
- ]
234
-
235
- verboseLog(`git-filter-repo refs: ${forkPoint}..${emitBranchName}`)
236
- verboseLog(
237
- `git-filter-repo will evaluate ${filesToFilter.length} filtered path${filesToFilter.length === 1 ? "" : "s"}`,
238
- )
239
- yield* logFilterPreflight(
240
- gitRoot,
241
- forkPoint,
242
- emitBranchName,
243
- filesToFilter,
244
- verboseLog,
245
- )
246
-
247
- yield* filterRepo.run(gitRoot, filterRepoArgs, {
248
- env: { GIT_CONFIG_GLOBAL: "" },
249
- verboseLog,
250
- streamOutput: verbose,
251
- progressIntervalMs: 5_000,
252
- })
253
-
254
- verboseLog("git-filter-repo completed")
255
-
256
- // Switch back to source branch (git-filter-repo may have checked out the emit branch)
257
- yield* git.checkoutBranch(gitRoot, currentBranch)
258
- })
259
-
260
- yield* withSpinner(filterOperation, {
261
- text: "Filtering backpack files from branch history",
262
- successText: "Filtered backpack files from branch history",
263
- enabled: !options.silent && !verbose,
264
- })
265
-
266
- yield* validateClaudeDoesNotReferenceAgency(gitRoot, emitBranchName)
267
-
268
- log(done(`Emitted ${highlight.branch(emitBranchName)}`))
269
- })
270
-
271
- const validateClaudeDoesNotReferenceAgency = (
272
- gitRoot: string,
273
- emitBranchName: string,
274
- ) =>
275
- Effect.gen(function* () {
276
- const git = yield* GitService
277
- const claudeContents = yield* git.getFileAtRef(
278
- gitRoot,
279
- emitBranchName,
280
- "CLAUDE.md",
281
- )
282
-
283
- if (!claudeContents?.includes("@AGENCY.md")) {
284
- return
285
- }
286
-
287
- return yield* Effect.fail(
288
- new Error(
289
- `CLAUDE.md on emitted branch ${emitBranchName} still references @AGENCY.md. Remove the agency file reference before emitting.`,
290
- ),
291
- )
292
- })
293
-
294
- const logFilterPreflight = (
295
- gitRoot: string,
296
- forkPoint: string,
297
- emitBranchName: string,
298
- filesToFilter: readonly string[],
299
- verboseLog: (message: string) => void,
300
- ) =>
301
- Effect.gen(function* () {
302
- const git = yield* GitService
303
- const range = `${forkPoint}..${emitBranchName}`
304
- const summarize = (value: string) => value.trim() || "0"
305
-
306
- const commitCount = yield* git
307
- .runGitCommand(["git", "rev-list", "--count", range], gitRoot, {
308
- captureOutput: true,
309
- })
310
- .pipe(Effect.option)
311
-
312
- if (commitCount._tag === "Some" && commitCount.value.exitCode === 0) {
313
- verboseLog(
314
- `Commit range contains ${summarize(commitCount.value.stdout)} commit${summarize(commitCount.value.stdout) === "1" ? "" : "s"}`,
315
- )
316
- } else {
317
- verboseLog("Unable to count commits in git-filter-repo range")
318
- }
319
-
320
- if (filesToFilter.length === 0) {
321
- verboseLog("No filtered paths were resolved before git-filter-repo")
322
- return
323
- }
324
-
325
- const pathArgs = ["--", ...filesToFilter]
326
- const matchingCommitCount = yield* git
327
- .runGitCommand(
328
- ["git", "rev-list", "--count", range, ...pathArgs],
329
- gitRoot,
330
- { captureOutput: true },
331
- )
332
- .pipe(Effect.option)
333
-
334
- if (
335
- matchingCommitCount._tag === "Some" &&
336
- matchingCommitCount.value.exitCode === 0
337
- ) {
338
- verboseLog(
339
- `Filtered paths are touched by ${summarize(matchingCommitCount.value.stdout)} commit${summarize(matchingCommitCount.value.stdout) === "1" ? "" : "s"} in the range`,
340
- )
341
- } else {
342
- verboseLog("Unable to count commits touching filtered paths")
343
- }
344
-
345
- const changedFiles = yield* git
346
- .runGitCommand(
347
- ["git", "diff", "--name-only", range, ...pathArgs],
348
- gitRoot,
349
- { captureOutput: true },
350
- )
351
- .pipe(Effect.option)
352
-
353
- if (changedFiles._tag === "Some" && changedFiles.value.exitCode === 0) {
354
- const changedFileCount = changedFiles.value.stdout
355
- .split("\n")
356
- .filter(Boolean).length
357
- verboseLog(
358
- `Filtered path diff contains ${changedFileCount} changed file${changedFileCount === 1 ? "" : "s"}`,
359
- )
360
- } else {
361
- verboseLog("Unable to count changed filtered files")
362
- }
363
- })
364
-
365
- // Helper: Ensure emitBranch is set in agency.json metadata
366
- const ensureEmitBranchInMetadata = (
367
- gitRoot: string,
368
- currentBranch: string,
369
- emitBranchName: string,
370
- ) =>
371
- Effect.gen(function* () {
372
- const git = yield* GitService
373
-
374
- // Read existing metadata
375
- const metadata = yield* Effect.tryPromise({
376
- try: () => readAgencyMetadata(gitRoot),
377
- catch: (error) => new Error(`Failed to read agency metadata: ${error}`),
378
- })
379
-
380
- // If no metadata exists, skip this step
381
- if (!metadata) {
382
- return
383
- }
384
-
385
- // If emitBranch is already set, nothing to do
386
- if (metadata.emitBranch) {
387
- return
388
- }
389
-
390
- // Add emitBranch to metadata
391
- const updatedMetadata = {
392
- ...metadata,
393
- emitBranch: emitBranchName,
394
- }
395
-
396
- // Write updated metadata
397
- yield* Effect.tryPromise({
398
- try: () => writeAgencyMetadata(gitRoot, updatedMetadata),
399
- catch: (error) => new Error(`Failed to write agency metadata: ${error}`),
400
- })
401
-
402
- // Stage and commit the change
403
- yield* git.gitAdd(["agency.json"], gitRoot)
404
- // Note: baseBranch not available in this helper function context
405
- // This commit only happens when backfilling emitBranch in legacy repos
406
- yield* git.gitCommit(
407
- `chore: agency emit ${currentBranch} → ${emitBranchName}`,
408
- gitRoot,
409
- { noVerify: true },
410
- )
411
- })
412
-
413
- /**
414
- * Get the fork point for a branch against a reference.
415
- * Falls back to merge-base if fork-point command fails.
416
- */
417
- const getForkPointOrMergeBase = (
418
- git: GitService,
419
- gitRoot: string,
420
- referenceBranch: string,
421
- featureBranch: string,
422
- ) =>
423
- git.getMergeBaseForkPoint(gitRoot, referenceBranch, featureBranch).pipe(
424
- Effect.catchAll(() =>
425
- // Fork-point can fail if the reflog doesn't have enough history
426
- git.getMergeBase(gitRoot, featureBranch, referenceBranch),
427
- ),
428
- )
429
-
430
- /**
431
- * Get the remote tracking branch name for a local branch.
432
- * Returns null if the branch doesn't track a remote.
433
- */
434
- const getRemoteTrackingBranch = (
435
- git: GitService,
436
- gitRoot: string,
437
- localBranch: string,
438
- ) =>
439
- Effect.gen(function* () {
440
- const remote = yield* git
441
- .getGitConfig(`branch.${localBranch}.remote`, gitRoot)
442
- .pipe(Effect.option)
443
-
444
- const remoteBranch = yield* git
445
- .getGitConfig(`branch.${localBranch}.merge`, gitRoot)
446
- .pipe(Effect.option)
447
-
448
- if (
449
- remote._tag === "Some" &&
450
- remoteBranch._tag === "Some" &&
451
- remoteBranch.value
452
- ) {
453
- return `${remote.value}/${remoteBranch.value.replace("refs/heads/", "")}`
454
- }
455
-
456
- return null
457
- })
458
-
459
- const getFilterRepoStateDir = (gitRoot: string) =>
460
- Effect.gen(function* () {
461
- const git = yield* GitService
462
- const result = yield* git.runGitCommand(
463
- ["git", "rev-parse", "--git-path", "filter-repo"],
464
- gitRoot,
465
- { captureOutput: true },
466
- )
467
-
468
- const gitPath = result.stdout.trim()
469
- if (result.exitCode !== 0 || !gitPath) {
470
- return `${gitRoot}/.git/filter-repo`
471
- }
472
-
473
- return resolveGitInternalPath(gitRoot, gitPath)
474
- })
475
-
476
- const configureEmitBranchTracking = (
477
- gitRoot: string,
478
- emitBranchName: string,
479
- verboseLog: (message: string) => void,
480
- ) =>
481
- Effect.gen(function* () {
482
- const git = yield* GitService
483
- const remote = yield* git.resolveRemote(gitRoot).pipe(Effect.option)
484
-
485
- if (remote._tag === "None") {
486
- yield* git.unsetGitConfig(`branch.${emitBranchName}.remote`, gitRoot)
487
- yield* git.unsetGitConfig(`branch.${emitBranchName}.merge`, gitRoot)
488
- return
489
- }
490
-
491
- const remoteBranch = `${remote.value}/${emitBranchName}`
492
- const remoteBranchExists = yield* git.branchExists(gitRoot, remoteBranch)
493
-
494
- if (!remoteBranchExists) {
495
- yield* git.unsetGitConfig(`branch.${emitBranchName}.remote`, gitRoot)
496
- yield* git.unsetGitConfig(`branch.${emitBranchName}.merge`, gitRoot)
497
- verboseLog(
498
- `No existing remote branch ${highlight.branch(remoteBranch)}; leaving ${highlight.branch(emitBranchName)} without upstream`,
499
- )
500
- return
501
- }
502
-
503
- yield* git.setGitConfig(
504
- `branch.${emitBranchName}.remote`,
505
- remote.value,
506
- gitRoot,
507
- )
508
- yield* git.setGitConfig(
509
- `branch.${emitBranchName}.merge`,
510
- `refs/heads/${emitBranchName}`,
511
- gitRoot,
512
- )
513
- verboseLog(
514
- `Set ${highlight.branch(emitBranchName)} upstream to ${highlight.branch(remoteBranch)}`,
515
- )
516
- })
517
-
518
- /**
519
- * Find the best fork point by checking both local and remote tracking branches.
520
- *
521
- * Strategy:
522
- * 1. Get fork-point against the local base branch
523
- * 2. If base branch tracks a remote, also get fork-point against remote tracking branch
524
- * 3. Choose the more recent fork point (prefer remote if local is its ancestor)
525
- */
526
- const findBestForkPoint = (
527
- gitRoot: string,
528
- featureBranch: string,
529
- baseBranch: string,
530
- options: Pick<EmitOptions, "silent" | "verbose">,
531
- ) =>
532
- Effect.gen(function* () {
533
- const git = yield* GitService
534
- const { verboseLog } = createLoggers(options)
535
-
536
- // Strategy 1: Get fork-point against local base branch
537
- const localForkPoint = yield* getForkPointOrMergeBase(
538
- git,
539
- gitRoot,
540
- baseBranch,
541
- featureBranch,
542
- )
543
- verboseLog(
544
- `Fork-point with ${highlight.branch(baseBranch)}: ${highlight.commit(localForkPoint.substring(0, 8))}`,
545
- )
546
-
547
- // Strategy 2: Check if base branch tracks a remote
548
- const remoteTrackingBranch = yield* getRemoteTrackingBranch(
549
- git,
550
- gitRoot,
551
- baseBranch,
552
- )
553
-
554
- if (!remoteTrackingBranch) {
555
- return localForkPoint
556
- }
557
-
558
- // Get fork-point against remote tracking branch
559
- const remoteForkPoint = yield* getForkPointOrMergeBase(
560
- git,
561
- gitRoot,
562
- remoteTrackingBranch,
563
- featureBranch,
564
- )
565
- verboseLog(
566
- `Fork-point with ${highlight.branch(remoteTrackingBranch)}: ${highlight.commit(remoteForkPoint.substring(0, 8))}`,
567
- )
568
-
569
- // Strategy 3: Choose the more recent fork point
570
- // If local fork point is an ancestor of remote, prefer remote (it's more recent)
571
- const localIsAncestorOfRemote = yield* git.isAncestor(
572
- gitRoot,
573
- localForkPoint,
574
- remoteForkPoint,
575
- )
576
-
577
- if (localIsAncestorOfRemote) {
578
- verboseLog(
579
- `Using remote fork-point ${highlight.commit(remoteForkPoint.substring(0, 8))} (local is ancestor)`,
580
- )
581
- return remoteForkPoint
582
- }
583
-
584
- verboseLog(
585
- `Using local fork-point ${highlight.commit(localForkPoint.substring(0, 8))}`,
586
- )
587
- return localForkPoint
588
- })
589
-
590
- // Helper: Create or reset branch
591
- const createOrResetBranchEffect = (
592
- gitRoot: string,
593
- sourceBranch: string,
594
- targetBranch: string,
595
- ) =>
596
- Effect.gen(function* () {
597
- const git = yield* GitService
598
-
599
- const exists = yield* git.branchExists(gitRoot, targetBranch)
600
- const currentBranch = yield* git.getCurrentBranch(gitRoot)
601
-
602
- if (exists) {
603
- // If we're currently on the target branch, switch away first
604
- if (currentBranch === targetBranch) {
605
- yield* git.checkoutBranch(gitRoot, sourceBranch)
606
- }
607
-
608
- // Delete the existing branch
609
- yield* git.deleteBranch(gitRoot, targetBranch, true)
610
- }
611
-
612
- // Create new branch from source
613
- yield* git.createBranch(targetBranch, gitRoot, sourceBranch)
614
-
615
- return exists
616
- })
617
-
618
- export const help = `
619
- Usage: agency emit [base-branch] [options]
620
-
621
- Create an emit branch from the current source branch with backpack files (AGENTS.md, TASK.md, etc.)
622
- reverted to their state on the base branch.
623
-
624
- Source and Emit Branches:
625
- - Source branches: Your working branches with agency-specific files (e.g., agency--feature-foo)
626
- - Emit branches: Clean branches suitable for PRs without agency files (e.g., feature-foo)
627
-
628
- This command creates a clean emit branch from your source branch by filtering out
629
- backpack files. Your source branch remains completely untouched.
630
-
631
- Behavior:
632
- - If a file existed on the base branch: It is reverted to that version
633
- - If a file did NOT exist on base branch: It is completely removed
634
- - Only commits since the branch diverged are rewritten
635
- - This allows you to layer feature-specific instructions on top of base instructions
636
- during development, then remove those modifications when submitting code
637
-
638
- Base Branch Selection:
639
- The command determines the base branch in this order:
640
- 1. Explicitly provided base-branch argument
641
- 2. Branch-specific base branch from agency.json (set by 'agency task')
642
- 3. Repository-level default base branch from .git/config (all branches)
643
- 4. Auto-detected from origin/HEAD or common branches (origin/main, origin/master, etc.)
644
-
645
- The base branch is set when you run 'agency task' to initialize a feature branch.
646
- Set a repository-level default with: agency base set --repo <branch>
647
- Update a branch's base branch with: agency base set <branch>
648
-
649
- Prerequisites:
650
- - git-filter-repo must be installed: brew install git-filter-repo
651
-
652
- Arguments:
653
- base-branch Base branch to compare against (e.g., origin/main)
654
- If not provided, will use saved config or prompt interactively
655
-
656
- Options:
657
- --emit Custom name for emit branch (defaults to pattern from config)
658
- --branch (Deprecated: use --emit) Custom name for emit branch
659
- -f, --force Force emit branch creation even if current branch looks like an emit branch
660
-
661
- Configuration:
662
- ~/.config/agency/agency.json can contain:
663
- {
664
- "sourceBranchPattern": "agency--%branch%", // Pattern for source branch names
665
- "emitBranch": "%branch%" // Pattern for emit branch names
666
- }
667
-
668
- Use %branch% as placeholder for the clean branch name.
669
-
670
- Source Pattern Examples:
671
- "agency--%branch%" -> main becomes agency--main (default)
672
- "wip/%branch%" -> feature becomes wip/feature
673
-
674
- Emit Pattern Examples:
675
- "%branch%" -> agency--main emits to main (default)
676
- "%branch%--PR" -> agency--feature emits to feature--PR
677
- "PR/%branch%" -> agency--feature emits to PR/feature
678
-
679
- Examples:
680
- agency emit # Prompt for base branch (first time) or use saved
681
- agency emit origin/main # Explicitly use origin/main as base branch
682
- agency emit --force # Force creation even from an emit branch
683
-
684
- Notes:
685
- - Emit branch is created from your current branch (not the base)
686
- - Base branch is set when you run 'agency task' to initialize the feature branch
687
- - Only commits since the branch diverged are rewritten (uses merge-base range)
688
- - Backpack files are reverted to their merge-base state (or removed if they didn't exist)
689
- - All commits from the base branch remain unchanged (shared history is preserved)
690
- - Original branch is never modified
691
- - If emit branch exists, it will be deleted and recreated
692
- - Command will refuse to create emit branch from an emit branch unless --force is used
693
-
694
- Important: If using a remote base branch (e.g., origin/main):
695
- - Always fetch before rebasing: git fetch origin && git rebase origin/main
696
- - Or use: git pull --rebase origin main
697
- - This ensures your local origin/main ref is up to date
698
- - If origin/main is stale when you rebase and emit, the emit branch may not be
699
- properly based on the remote, requiring manual rebasing of the emit branch
700
- `