@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.
- package/LICENSE +21 -0
- package/README.md +109 -0
- package/cli.ts +569 -0
- package/index.ts +1 -0
- package/package.json +65 -0
- package/src/commands/base.test.ts +198 -0
- package/src/commands/base.ts +198 -0
- package/src/commands/clean.test.ts +299 -0
- package/src/commands/clean.ts +320 -0
- package/src/commands/emit.test.ts +412 -0
- package/src/commands/emit.ts +521 -0
- package/src/commands/emitted.test.ts +226 -0
- package/src/commands/emitted.ts +57 -0
- package/src/commands/init.test.ts +311 -0
- package/src/commands/init.ts +140 -0
- package/src/commands/merge.test.ts +365 -0
- package/src/commands/merge.ts +253 -0
- package/src/commands/pull.test.ts +385 -0
- package/src/commands/pull.ts +205 -0
- package/src/commands/push.test.ts +394 -0
- package/src/commands/push.ts +346 -0
- package/src/commands/save.test.ts +247 -0
- package/src/commands/save.ts +162 -0
- package/src/commands/source.test.ts +195 -0
- package/src/commands/source.ts +72 -0
- package/src/commands/status.test.ts +489 -0
- package/src/commands/status.ts +258 -0
- package/src/commands/switch.test.ts +194 -0
- package/src/commands/switch.ts +84 -0
- package/src/commands/task-branching.test.ts +334 -0
- package/src/commands/task-edit.test.ts +141 -0
- package/src/commands/task-main.test.ts +872 -0
- package/src/commands/task.ts +712 -0
- package/src/commands/tasks.test.ts +335 -0
- package/src/commands/tasks.ts +155 -0
- package/src/commands/template-delete.test.ts +178 -0
- package/src/commands/template-delete.ts +98 -0
- package/src/commands/template-list.test.ts +135 -0
- package/src/commands/template-list.ts +87 -0
- package/src/commands/template-view.test.ts +158 -0
- package/src/commands/template-view.ts +86 -0
- package/src/commands/template.test.ts +32 -0
- package/src/commands/template.ts +96 -0
- package/src/commands/use.test.ts +87 -0
- package/src/commands/use.ts +97 -0
- package/src/commands/work.test.ts +462 -0
- package/src/commands/work.ts +193 -0
- package/src/errors.ts +17 -0
- package/src/schemas.ts +33 -0
- package/src/services/AgencyMetadataService.ts +287 -0
- package/src/services/ClaudeService.test.ts +184 -0
- package/src/services/ClaudeService.ts +91 -0
- package/src/services/ConfigService.ts +115 -0
- package/src/services/FileSystemService.ts +222 -0
- package/src/services/GitService.ts +751 -0
- package/src/services/OpencodeService.ts +263 -0
- package/src/services/PromptService.ts +183 -0
- package/src/services/TemplateService.ts +75 -0
- package/src/test-utils.ts +362 -0
- package/src/types/native-exec.d.ts +8 -0
- package/src/types.ts +216 -0
- package/src/utils/colors.ts +178 -0
- package/src/utils/command.ts +17 -0
- package/src/utils/effect.ts +281 -0
- package/src/utils/exec.ts +48 -0
- package/src/utils/paths.ts +51 -0
- package/src/utils/pr-branch.test.ts +372 -0
- package/src/utils/pr-branch.ts +473 -0
- package/src/utils/process.ts +110 -0
- package/src/utils/spinner.ts +82 -0
- package/templates/AGENCY.md +20 -0
- package/templates/AGENTS.md +11 -0
- package/templates/CLAUDE.md +3 -0
- package/templates/TASK.md +5 -0
- 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
|
+
`
|