@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,299 @@
1
+ import { test, expect, describe, beforeEach, afterEach } from "bun:test"
2
+ import { join } from "path"
3
+ import { clean } from "./clean"
4
+ import {
5
+ createTempDir,
6
+ cleanupTempDir,
7
+ initGitRepo,
8
+ initAgency,
9
+ getGitOutput,
10
+ getCurrentBranch,
11
+ createCommit,
12
+ checkoutBranch,
13
+ runTestEffect,
14
+ } from "../test-utils"
15
+
16
+ describe("clean command", () => {
17
+ let tempDir: string
18
+ let originalCwd: string
19
+
20
+ beforeEach(async () => {
21
+ tempDir = await createTempDir()
22
+ originalCwd = process.cwd()
23
+ process.chdir(tempDir)
24
+
25
+ // Set config path to non-existent file to use defaults
26
+ process.env.AGENCY_CONFIG_PATH = join(tempDir, "non-existent-config.json")
27
+ // Set config dir to temp dir to avoid picking up user's config files
28
+ process.env.AGENCY_CONFIG_DIR = await createTempDir()
29
+
30
+ // Initialize git repo with main branch
31
+ await initGitRepo(tempDir)
32
+
33
+ // Initialize agency in main branch
34
+ await initAgency(tempDir, "test")
35
+ })
36
+
37
+ afterEach(async () => {
38
+ process.chdir(originalCwd)
39
+ delete process.env.AGENCY_CONFIG_PATH
40
+ if (process.env.AGENCY_CONFIG_DIR) {
41
+ await cleanupTempDir(process.env.AGENCY_CONFIG_DIR)
42
+ delete process.env.AGENCY_CONFIG_DIR
43
+ }
44
+ await cleanupTempDir(tempDir)
45
+ })
46
+
47
+ describe("--merged-into flag requirement", () => {
48
+ test("throws error when --merged-into flag is not provided", async () => {
49
+ expect(runTestEffect(clean({ silent: true }))).rejects.toThrow(
50
+ "--merged-into flag is required",
51
+ )
52
+ })
53
+
54
+ test("throws error when specified branch does not exist", async () => {
55
+ expect(
56
+ runTestEffect(clean({ mergedInto: "nonexistent", silent: true })),
57
+ ).rejects.toThrow("does not exist")
58
+ })
59
+ })
60
+
61
+ describe("basic functionality", () => {
62
+ test("finds and deletes branches merged into target", async () => {
63
+ // Create feature branch, make commits, and merge to main
64
+ await checkoutBranch(tempDir, "main")
65
+ await Bun.spawn(["git", "checkout", "-b", "feature-1"], {
66
+ cwd: tempDir,
67
+ stdout: "pipe",
68
+ stderr: "pipe",
69
+ }).exited
70
+ await createCommit(tempDir, "Feature 1 commit")
71
+ await checkoutBranch(tempDir, "main")
72
+ await Bun.spawn(["git", "merge", "--no-ff", "feature-1"], {
73
+ cwd: tempDir,
74
+ stdout: "pipe",
75
+ stderr: "pipe",
76
+ }).exited
77
+
78
+ // Run clean command
79
+ await runTestEffect(clean({ mergedInto: "main", silent: true }))
80
+
81
+ // Verify feature-1 was deleted
82
+ const branches = await getGitOutput(tempDir, ["branch", "--list"])
83
+ expect(branches).not.toContain("feature-1")
84
+ expect(branches).toContain("main")
85
+ })
86
+
87
+ test("finds source branches for merged emit branches", async () => {
88
+ // Create source branch with agency pattern
89
+ await checkoutBranch(tempDir, "main")
90
+ await Bun.spawn(["git", "checkout", "-b", "agency/feature-2"], {
91
+ cwd: tempDir,
92
+ stdout: "pipe",
93
+ stderr: "pipe",
94
+ }).exited
95
+
96
+ // Create agency.json with emitBranch
97
+ await Bun.write(
98
+ join(tempDir, "agency.json"),
99
+ JSON.stringify({
100
+ version: 1,
101
+ injectedFiles: ["AGENTS.md"],
102
+ template: "test",
103
+ emitBranch: "feature-2",
104
+ createdAt: new Date().toISOString(),
105
+ }),
106
+ )
107
+ await Bun.spawn(["git", "add", "agency.json"], {
108
+ cwd: tempDir,
109
+ stdout: "pipe",
110
+ stderr: "pipe",
111
+ }).exited
112
+ await createCommit(tempDir, "Feature 2 commit")
113
+
114
+ // Create the emit branch manually
115
+ await Bun.spawn(["git", "checkout", "-b", "feature-2"], {
116
+ cwd: tempDir,
117
+ stdout: "pipe",
118
+ stderr: "pipe",
119
+ }).exited
120
+
121
+ // Merge emit branch to main
122
+ await checkoutBranch(tempDir, "main")
123
+ await Bun.spawn(["git", "merge", "--no-ff", "feature-2"], {
124
+ cwd: tempDir,
125
+ stdout: "pipe",
126
+ stderr: "pipe",
127
+ }).exited
128
+
129
+ // Run clean command
130
+ await runTestEffect(clean({ mergedInto: "main", silent: true }))
131
+
132
+ // Verify both emit and source branches were deleted
133
+ const branches = await getGitOutput(tempDir, ["branch", "--list"])
134
+ expect(branches).not.toContain("feature-2")
135
+ expect(branches).not.toContain("agency/feature-2")
136
+ expect(branches).toContain("main")
137
+ })
138
+
139
+ test("does not delete unmerged branches", async () => {
140
+ // Create an unmerged feature branch
141
+ await checkoutBranch(tempDir, "main")
142
+ await Bun.spawn(["git", "checkout", "-b", "feature-unmerged"], {
143
+ cwd: tempDir,
144
+ stdout: "pipe",
145
+ stderr: "pipe",
146
+ }).exited
147
+ await createCommit(tempDir, "Unmerged commit")
148
+
149
+ // Run clean command
150
+ await checkoutBranch(tempDir, "main")
151
+ await runTestEffect(clean({ mergedInto: "main", silent: true }))
152
+
153
+ // Verify unmerged branch still exists
154
+ const branches = await getGitOutput(tempDir, ["branch", "--list"])
155
+ expect(branches).toContain("feature-unmerged")
156
+ })
157
+
158
+ test("does not delete the target branch itself", async () => {
159
+ // Run clean on main (nothing should happen)
160
+ await checkoutBranch(tempDir, "main")
161
+ await runTestEffect(clean({ mergedInto: "main", silent: true }))
162
+
163
+ // Verify main still exists
164
+ const branches = await getGitOutput(tempDir, ["branch", "--list"])
165
+ expect(branches).toContain("main")
166
+ })
167
+ })
168
+
169
+ describe("dry-run mode", () => {
170
+ test("shows branches without deleting in dry-run mode", async () => {
171
+ // Create and merge feature branch
172
+ await checkoutBranch(tempDir, "main")
173
+ await Bun.spawn(["git", "checkout", "-b", "feature-dry"], {
174
+ cwd: tempDir,
175
+ stdout: "pipe",
176
+ stderr: "pipe",
177
+ }).exited
178
+ await createCommit(tempDir, "Feature dry commit")
179
+ await checkoutBranch(tempDir, "main")
180
+ await Bun.spawn(["git", "merge", "--no-ff", "feature-dry"], {
181
+ cwd: tempDir,
182
+ stdout: "pipe",
183
+ stderr: "pipe",
184
+ }).exited
185
+
186
+ // Run clean in dry-run mode
187
+ await runTestEffect(
188
+ clean({ mergedInto: "main", dryRun: true, silent: true }),
189
+ )
190
+
191
+ // Verify branch still exists
192
+ const branches = await getGitOutput(tempDir, ["branch", "--list"])
193
+ expect(branches).toContain("feature-dry")
194
+ })
195
+ })
196
+
197
+ describe("branch switching", () => {
198
+ test("switches away from branch being deleted if currently on it", async () => {
199
+ // Create and merge feature branch
200
+ await checkoutBranch(tempDir, "main")
201
+ await Bun.spawn(["git", "checkout", "-b", "feature-switch"], {
202
+ cwd: tempDir,
203
+ stdout: "pipe",
204
+ stderr: "pipe",
205
+ }).exited
206
+ await createCommit(tempDir, "Feature switch commit")
207
+ await checkoutBranch(tempDir, "main")
208
+ await Bun.spawn(["git", "merge", "--no-ff", "feature-switch"], {
209
+ cwd: tempDir,
210
+ stdout: "pipe",
211
+ stderr: "pipe",
212
+ }).exited
213
+
214
+ // Checkout the branch that will be deleted
215
+ await checkoutBranch(tempDir, "feature-switch")
216
+
217
+ // Run clean command
218
+ await runTestEffect(clean({ mergedInto: "main", silent: true }))
219
+
220
+ // Verify we're now on main
221
+ const currentBranch = await getCurrentBranch(tempDir)
222
+ expect(currentBranch).toBe("main")
223
+
224
+ // Verify branch was deleted
225
+ const branches = await getGitOutput(tempDir, ["branch", "--list"])
226
+ expect(branches).not.toContain("feature-switch")
227
+ })
228
+ })
229
+
230
+ describe("multiple branches", () => {
231
+ test("deletes multiple merged branches at once", async () => {
232
+ await checkoutBranch(tempDir, "main")
233
+
234
+ // Create and merge feature-1
235
+ await Bun.spawn(["git", "checkout", "-b", "feature-1"], {
236
+ cwd: tempDir,
237
+ stdout: "pipe",
238
+ stderr: "pipe",
239
+ }).exited
240
+ await createCommit(tempDir, "Feature 1 commit")
241
+ await checkoutBranch(tempDir, "main")
242
+ await Bun.spawn(["git", "merge", "--no-ff", "feature-1"], {
243
+ cwd: tempDir,
244
+ stdout: "pipe",
245
+ stderr: "pipe",
246
+ }).exited
247
+
248
+ // Create and merge feature-2
249
+ await Bun.spawn(["git", "checkout", "-b", "feature-2"], {
250
+ cwd: tempDir,
251
+ stdout: "pipe",
252
+ stderr: "pipe",
253
+ }).exited
254
+ await createCommit(tempDir, "Feature 2 commit")
255
+ await checkoutBranch(tempDir, "main")
256
+ await Bun.spawn(["git", "merge", "--no-ff", "feature-2"], {
257
+ cwd: tempDir,
258
+ stdout: "pipe",
259
+ stderr: "pipe",
260
+ }).exited
261
+
262
+ // Run clean command
263
+ await runTestEffect(clean({ mergedInto: "main", silent: true }))
264
+
265
+ // Verify both branches were deleted
266
+ const branches = await getGitOutput(tempDir, ["branch", "--list"])
267
+ expect(branches).not.toContain("feature-1")
268
+ expect(branches).not.toContain("feature-2")
269
+ expect(branches).toContain("main")
270
+ })
271
+ })
272
+
273
+ describe("no merged branches", () => {
274
+ test("handles case with no merged branches gracefully", async () => {
275
+ // Don't create any feature branches, just run clean
276
+ await checkoutBranch(tempDir, "main")
277
+
278
+ // Should not throw
279
+ await runTestEffect(clean({ mergedInto: "main", silent: true }))
280
+
281
+ // Main should still exist
282
+ const branches = await getGitOutput(tempDir, ["branch", "--list"])
283
+ expect(branches).toContain("main")
284
+ })
285
+ })
286
+
287
+ describe("error handling", () => {
288
+ test("throws error when not in a git repository", async () => {
289
+ const nonGitDir = await createTempDir()
290
+ process.chdir(nonGitDir)
291
+
292
+ expect(
293
+ runTestEffect(clean({ mergedInto: "main", silent: true })),
294
+ ).rejects.toThrow("Not in a git repository")
295
+
296
+ await cleanupTempDir(nonGitDir)
297
+ })
298
+ })
299
+ })
@@ -0,0 +1,320 @@
1
+ import { Effect } from "effect"
2
+ import { Schema } from "@effect/schema"
3
+ import type { BaseCommandOptions } from "../utils/command"
4
+ import { GitService } from "../services/GitService"
5
+ import { ConfigService } from "../services/ConfigService"
6
+ import { AgencyMetadata } from "../schemas"
7
+ import highlight, { done } from "../utils/colors"
8
+ import { createLoggers, ensureGitRepo } from "../utils/effect"
9
+ import { extractCleanBranch, makeSourceBranchName } from "../utils/pr-branch"
10
+
11
+ interface CleanOptions extends BaseCommandOptions {
12
+ dryRun?: boolean
13
+ mergedInto?: string
14
+ }
15
+
16
+ /**
17
+ * Read agency.json metadata from a specific branch without checking it out.
18
+ * Uses `git show` to read the file contents directly.
19
+ */
20
+ const readAgencyMetadata = (gitRoot: string, branch: string) =>
21
+ Effect.gen(function* () {
22
+ const git = yield* GitService
23
+
24
+ // Use git show to read agency.json from the branch without checking out
25
+ const content = yield* git.getFileAtRef(gitRoot, branch, "agency.json")
26
+
27
+ if (!content) {
28
+ return null
29
+ }
30
+
31
+ const data = yield* Effect.try({
32
+ try: () => JSON.parse(content),
33
+ catch: () => new Error("Failed to parse agency.json"),
34
+ })
35
+
36
+ // Validate version
37
+ if (typeof data.version !== "number" || data.version !== 1) {
38
+ return null
39
+ }
40
+
41
+ // Parse and validate using Effect schema
42
+ const metadata: AgencyMetadata | null = yield* Effect.try({
43
+ try: () => Schema.decodeUnknownSync(AgencyMetadata)(data),
44
+ catch: () => new Error("Invalid agency.json format"),
45
+ }).pipe(Effect.catchAll(() => Effect.succeed(null)))
46
+
47
+ return metadata
48
+ }).pipe(Effect.catchAll(() => Effect.succeed(null)))
49
+
50
+ /**
51
+ * Get all local branches
52
+ */
53
+ const getAllLocalBranches = (gitRoot: string) =>
54
+ Effect.gen(function* () {
55
+ const git = yield* GitService
56
+
57
+ // Get all local branches using git branch --format
58
+ const result = yield* git.runGitCommand(
59
+ ["git", "branch", "--format=%(refname:short)"],
60
+ gitRoot,
61
+ { captureOutput: true },
62
+ )
63
+
64
+ if (result.exitCode !== 0) {
65
+ return []
66
+ }
67
+
68
+ return result.stdout
69
+ .split("\n")
70
+ .map((line) => line.trim())
71
+ .filter((line) => line.length > 0)
72
+ }).pipe(Effect.catchAll(() => Effect.succeed([])))
73
+
74
+ /**
75
+ * Get all branches that have been fully merged into the specified branch
76
+ */
77
+ const getBranchesMergedInto = (
78
+ gitRoot: string,
79
+ targetBranch: string,
80
+ options: BaseCommandOptions,
81
+ ) =>
82
+ Effect.gen(function* () {
83
+ const git = yield* GitService
84
+ const { verboseLog } = createLoggers(options)
85
+
86
+ verboseLog(
87
+ `Finding branches merged into ${highlight.branch(targetBranch)}...`,
88
+ )
89
+
90
+ // Use git branch --merged to find all merged branches
91
+ const result = yield* git.runGitCommand(
92
+ ["git", "branch", "--merged", targetBranch, "--format=%(refname:short)"],
93
+ gitRoot,
94
+ { captureOutput: true },
95
+ )
96
+
97
+ if (result.exitCode !== 0) {
98
+ return []
99
+ }
100
+
101
+ const mergedBranches = result.stdout
102
+ .split("\n")
103
+ .map((line) => line.trim())
104
+ .filter((line) => line.length > 0)
105
+
106
+ verboseLog(`Found ${mergedBranches.length} merged branches`)
107
+ return mergedBranches
108
+ }).pipe(Effect.catchAll(() => Effect.succeed([])))
109
+
110
+ /**
111
+ * Find source branches for the given branches (emit branches).
112
+ * For each branch, check if there's a corresponding source branch by:
113
+ * 1. Looking for branches with agency.json that has this branch as emitBranch
114
+ * 2. Using the source branch pattern from config
115
+ */
116
+ const findSourceBranches = (
117
+ gitRoot: string,
118
+ branches: readonly string[],
119
+ sourcePattern: string,
120
+ emitPattern: string,
121
+ options: BaseCommandOptions,
122
+ ) =>
123
+ Effect.gen(function* () {
124
+ const git = yield* GitService
125
+ const { verboseLog } = createLoggers(options)
126
+
127
+ const allBranches = yield* getAllLocalBranches(gitRoot)
128
+ const sourceBranches: string[] = []
129
+
130
+ verboseLog(
131
+ `Looking for source branches for ${branches.length} emit branches...`,
132
+ )
133
+
134
+ // For each branch in our list, check if it's an emit branch and find its source
135
+ for (const branch of branches) {
136
+ // Strategy 1: Check all branches for agency.json with matching emitBranch
137
+ for (const candidateBranch of allBranches) {
138
+ if (candidateBranch === branch) continue
139
+
140
+ const metadata = yield* readAgencyMetadata(gitRoot, candidateBranch)
141
+
142
+ if (metadata?.emitBranch === branch) {
143
+ verboseLog(
144
+ `Found source branch ${highlight.branch(candidateBranch)} for emit branch ${highlight.branch(branch)}`,
145
+ )
146
+ if (!sourceBranches.includes(candidateBranch)) {
147
+ sourceBranches.push(candidateBranch)
148
+ }
149
+ }
150
+ }
151
+
152
+ // Strategy 2: Try using the source pattern
153
+ // If emitPattern is "%branch%", the branch name itself is the clean name
154
+ const cleanBranch =
155
+ emitPattern === "%branch%"
156
+ ? branch
157
+ : extractCleanBranch(branch, emitPattern)
158
+
159
+ if (cleanBranch) {
160
+ const possibleSourceBranch = makeSourceBranchName(
161
+ cleanBranch,
162
+ sourcePattern,
163
+ )
164
+ const sourceExists = yield* git
165
+ .branchExists(gitRoot, possibleSourceBranch)
166
+ .pipe(Effect.catchAll(() => Effect.succeed(false)))
167
+
168
+ if (sourceExists && !sourceBranches.includes(possibleSourceBranch)) {
169
+ verboseLog(
170
+ `Found source branch ${highlight.branch(possibleSourceBranch)} via pattern matching`,
171
+ )
172
+ sourceBranches.push(possibleSourceBranch)
173
+ }
174
+ }
175
+ }
176
+
177
+ verboseLog(`Found ${sourceBranches.length} source branches`)
178
+ return sourceBranches
179
+ })
180
+
181
+ export const clean = (options: CleanOptions = {}) =>
182
+ Effect.gen(function* () {
183
+ const { dryRun = false, mergedInto } = options
184
+ const { log, verboseLog } = createLoggers(options)
185
+
186
+ const git = yield* GitService
187
+ const configService = yield* ConfigService
188
+ const gitRoot = yield* ensureGitRepo()
189
+
190
+ // Require --merged-into flag
191
+ if (!mergedInto) {
192
+ return yield* Effect.fail(
193
+ new Error(
194
+ "The --merged-into flag is required. Specify which branch to check for merged branches (e.g., --merged-into main)",
195
+ ),
196
+ )
197
+ }
198
+
199
+ // Verify the target branch exists
200
+ const targetExists = yield* git.branchExists(gitRoot, mergedInto)
201
+ if (!targetExists) {
202
+ return yield* Effect.fail(
203
+ new Error(
204
+ `Branch ${highlight.branch(mergedInto)} does not exist. Please specify a valid branch.`,
205
+ ),
206
+ )
207
+ }
208
+
209
+ // Load config to get source and emit patterns
210
+ const config = yield* configService.loadConfig()
211
+ const sourcePattern = config.sourceBranchPattern || "agency/%branch%"
212
+ const emitPattern = config.emitBranch || "%branch%"
213
+
214
+ verboseLog(
215
+ `Using source pattern: ${sourcePattern}, emit pattern: ${emitPattern}`,
216
+ )
217
+
218
+ // Get all branches merged into target
219
+ const mergedBranches = yield* getBranchesMergedInto(
220
+ gitRoot,
221
+ mergedInto,
222
+ options,
223
+ )
224
+ verboseLog(
225
+ `Found ${mergedBranches.length} branches merged into ${highlight.branch(mergedInto)}`,
226
+ )
227
+
228
+ // Find source branches for the merged branches
229
+ const sourceBranches = yield* findSourceBranches(
230
+ gitRoot,
231
+ mergedBranches,
232
+ sourcePattern,
233
+ emitPattern,
234
+ options,
235
+ )
236
+
237
+ // Combine merged branches and their source branches
238
+ const branchesToDelete = [
239
+ ...new Set([...mergedBranches, ...sourceBranches]),
240
+ ]
241
+
242
+ // Filter out the target branch itself (we don't want to delete it)
243
+ const filteredBranches = branchesToDelete.filter(
244
+ (branch) => branch !== mergedInto,
245
+ )
246
+
247
+ if (filteredBranches.length === 0) {
248
+ log(
249
+ `No branches found that are merged into ${highlight.branch(mergedInto)}`,
250
+ )
251
+ return
252
+ }
253
+
254
+ // Show what will be deleted
255
+ const branchWord = filteredBranches.length === 1 ? "branch" : "branches"
256
+ log(
257
+ `Found ${filteredBranches.length} ${branchWord} to delete (merged into ${highlight.branch(mergedInto)}):`,
258
+ )
259
+ for (const branch of filteredBranches) {
260
+ log(` ${highlight.branch(branch)}`)
261
+ }
262
+
263
+ if (dryRun) {
264
+ log("")
265
+ log("Dry-run mode: no branches were deleted")
266
+ return
267
+ }
268
+
269
+ // Get current branch to avoid deleting it
270
+ const currentBranch = yield* git.getCurrentBranch(gitRoot)
271
+
272
+ // Delete all branches
273
+ log("")
274
+ for (const branch of filteredBranches) {
275
+ // If we're currently on the branch, switch away first
276
+ if (currentBranch === branch) {
277
+ verboseLog(
278
+ `Currently on ${highlight.branch(branch)}, switching to ${highlight.branch(mergedInto)}`,
279
+ )
280
+ yield* git.checkoutBranch(gitRoot, mergedInto)
281
+ }
282
+
283
+ verboseLog(`Deleting ${highlight.branch(branch)}...`)
284
+ yield* git.deleteBranch(gitRoot, branch, true)
285
+ }
286
+
287
+ const deletedBranchWord =
288
+ filteredBranches.length === 1 ? "branch" : "branches"
289
+ log(done(`Deleted ${filteredBranches.length} ${deletedBranchWord}`))
290
+ })
291
+
292
+ export const help = `
293
+ Usage: agency clean --merged-into <branch> [options]
294
+
295
+ Delete all branches that have been fully merged into a specified branch,
296
+ along with their corresponding source branches.
297
+
298
+ This command is useful for cleaning up branches after they've been merged.
299
+ It will:
300
+ 1. Find all branches fully merged into the specified branch
301
+ 2. Find the corresponding source branches (which won't show as merged due to emit filtering)
302
+ 3. Delete both the merged branches and their source branches
303
+
304
+ The --merged-into flag is REQUIRED to prevent accidental deletion of branches.
305
+
306
+ Options:
307
+ --merged-into <branch> Branch to check against (e.g., main, origin/main) [REQUIRED]
308
+ --dry-run Show what would be deleted without actually deleting
309
+
310
+ Safety:
311
+ - By default, this command deletes branches
312
+ - Use --dry-run to preview what would be deleted without making changes
313
+ - If currently on a branch that will be deleted, switches to the target branch first
314
+ - Uses force delete (git branch -D) to ensure branches are deleted
315
+
316
+ Examples:
317
+ agency clean --merged-into main # Delete branches merged into main
318
+ agency clean --merged-into main --dry-run # Preview what would be deleted
319
+ agency clean --merged-into origin/main # Delete branches merged into origin/main
320
+ `