@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,372 @@
|
|
|
1
|
+
import { describe, test, expect, afterEach } from "bun:test"
|
|
2
|
+
import {
|
|
3
|
+
makeSourceBranchName,
|
|
4
|
+
extractCleanBranch,
|
|
5
|
+
makeEmitBranchName,
|
|
6
|
+
extractCleanFromEmit,
|
|
7
|
+
resolveBranchPairWithAgencyJson,
|
|
8
|
+
} from "./pr-branch"
|
|
9
|
+
import {
|
|
10
|
+
createTempDir,
|
|
11
|
+
cleanupTempDir,
|
|
12
|
+
initGitRepo,
|
|
13
|
+
runTestEffect,
|
|
14
|
+
} from "../test-utils"
|
|
15
|
+
import { join } from "path"
|
|
16
|
+
|
|
17
|
+
describe("makeSourceBranchName", () => {
|
|
18
|
+
test("replaces %branch% placeholder with branch name", () => {
|
|
19
|
+
expect(makeSourceBranchName("main", "agency/%branch%")).toBe("agency/main")
|
|
20
|
+
expect(makeSourceBranchName("feature-foo", "wip/%branch%")).toBe(
|
|
21
|
+
"wip/feature-foo",
|
|
22
|
+
)
|
|
23
|
+
expect(makeSourceBranchName("main", "%branch%/dev")).toBe("main/dev")
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test("treats pattern as prefix when %branch% is not present", () => {
|
|
27
|
+
expect(makeSourceBranchName("main", "agency/")).toBe("agency/main")
|
|
28
|
+
expect(makeSourceBranchName("feature-foo", "wip-")).toBe("wip-feature-foo")
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test("handles empty branch name", () => {
|
|
32
|
+
expect(makeSourceBranchName("", "agency/%branch%")).toBe("agency/")
|
|
33
|
+
expect(makeSourceBranchName("", "agency/")).toBe("agency/")
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe("extractCleanBranch", () => {
|
|
38
|
+
describe("with %branch% placeholder", () => {
|
|
39
|
+
test("extracts clean branch from source branch name", () => {
|
|
40
|
+
expect(extractCleanBranch("agency/main", "agency/%branch%")).toBe("main")
|
|
41
|
+
expect(extractCleanBranch("wip/feature-foo", "wip/%branch%")).toBe(
|
|
42
|
+
"feature-foo",
|
|
43
|
+
)
|
|
44
|
+
expect(extractCleanBranch("main/dev", "%branch%/dev")).toBe("main")
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test("returns null when source branch name doesn't match pattern", () => {
|
|
48
|
+
expect(extractCleanBranch("main", "agency/%branch%")).toBeNull()
|
|
49
|
+
expect(extractCleanBranch("feature-foo", "wip/%branch%")).toBeNull()
|
|
50
|
+
expect(extractCleanBranch("agency/main", "wip/%branch%")).toBeNull()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test("returns null for empty clean branch", () => {
|
|
54
|
+
expect(extractCleanBranch("agency/", "agency/%branch%")).toBeNull()
|
|
55
|
+
expect(extractCleanBranch("wip/", "wip/%branch%")).toBeNull()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test("handles complex patterns", () => {
|
|
59
|
+
expect(
|
|
60
|
+
extractCleanBranch("pr-feature-foo-ready", "pr-%branch%-ready"),
|
|
61
|
+
).toBe("feature-foo")
|
|
62
|
+
expect(
|
|
63
|
+
extractCleanBranch("PR/feature/foo/ready", "PR/%branch%/ready"),
|
|
64
|
+
).toBe("feature/foo")
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
describe("without %branch% placeholder (prefix mode)", () => {
|
|
69
|
+
test("extracts clean branch by removing prefix", () => {
|
|
70
|
+
expect(extractCleanBranch("agency/main", "agency/")).toBe("main")
|
|
71
|
+
expect(extractCleanBranch("wip-feature-foo", "wip-")).toBe("feature-foo")
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test("returns null when branch doesn't start with prefix", () => {
|
|
75
|
+
expect(extractCleanBranch("main", "agency/")).toBeNull()
|
|
76
|
+
expect(extractCleanBranch("feature-foo", "wip-")).toBeNull()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test("returns null for empty clean branch", () => {
|
|
80
|
+
expect(extractCleanBranch("agency/", "agency/")).toBeNull()
|
|
81
|
+
expect(extractCleanBranch("wip-", "wip-")).toBeNull()
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe("makeEmitBranchName", () => {
|
|
87
|
+
test("returns clean branch when pattern is %branch%", () => {
|
|
88
|
+
expect(makeEmitBranchName("main", "%branch%")).toBe("main")
|
|
89
|
+
expect(makeEmitBranchName("feature-foo", "%branch%")).toBe("feature-foo")
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test("replaces %branch% placeholder with branch name", () => {
|
|
93
|
+
expect(makeEmitBranchName("feature-foo", "%branch%--PR")).toBe(
|
|
94
|
+
"feature-foo--PR",
|
|
95
|
+
)
|
|
96
|
+
expect(makeEmitBranchName("feature-foo", "PR/%branch%")).toBe(
|
|
97
|
+
"PR/feature-foo",
|
|
98
|
+
)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test("treats pattern as suffix when %branch% is not present", () => {
|
|
102
|
+
expect(makeEmitBranchName("feature-foo", "--PR")).toBe("feature-foo--PR")
|
|
103
|
+
expect(makeEmitBranchName("feature-foo", "-pr")).toBe("feature-foo-pr")
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test("handles empty branch name", () => {
|
|
107
|
+
expect(makeEmitBranchName("", "%branch%")).toBe("")
|
|
108
|
+
expect(makeEmitBranchName("", "%branch%--PR")).toBe("--PR")
|
|
109
|
+
expect(makeEmitBranchName("", "--PR")).toBe("--PR")
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
describe("extractCleanFromEmit", () => {
|
|
114
|
+
test("returns emit branch when pattern is %branch%", () => {
|
|
115
|
+
expect(extractCleanFromEmit("main", "%branch%")).toBe("main")
|
|
116
|
+
expect(extractCleanFromEmit("feature-foo", "%branch%")).toBe("feature-foo")
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
describe("with %branch% placeholder", () => {
|
|
120
|
+
test("extracts clean branch from emit branch name", () => {
|
|
121
|
+
expect(extractCleanFromEmit("feature-foo--PR", "%branch%--PR")).toBe(
|
|
122
|
+
"feature-foo",
|
|
123
|
+
)
|
|
124
|
+
expect(extractCleanFromEmit("PR/feature-foo", "PR/%branch%")).toBe(
|
|
125
|
+
"feature-foo",
|
|
126
|
+
)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test("returns null when emit branch name doesn't match pattern", () => {
|
|
130
|
+
expect(extractCleanFromEmit("feature-foo", "%branch%--PR")).toBeNull()
|
|
131
|
+
expect(extractCleanFromEmit("feature-foo--PR", "PR/%branch%")).toBeNull()
|
|
132
|
+
expect(extractCleanFromEmit("main", "%branch%--PR")).toBeNull()
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test("returns null for empty clean branch", () => {
|
|
136
|
+
expect(extractCleanFromEmit("--PR", "%branch%--PR")).toBeNull()
|
|
137
|
+
expect(extractCleanFromEmit("PR/", "PR/%branch%")).toBeNull()
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
test("handles complex patterns", () => {
|
|
141
|
+
expect(
|
|
142
|
+
extractCleanFromEmit("pr-feature-foo-ready", "pr-%branch%-ready"),
|
|
143
|
+
).toBe("feature-foo")
|
|
144
|
+
expect(
|
|
145
|
+
extractCleanFromEmit("PR/feature/foo/ready", "PR/%branch%/ready"),
|
|
146
|
+
).toBe("feature/foo")
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
describe("without %branch% placeholder (suffix mode)", () => {
|
|
151
|
+
test("extracts clean branch by removing suffix", () => {
|
|
152
|
+
expect(extractCleanFromEmit("feature-foo--PR", "--PR")).toBe(
|
|
153
|
+
"feature-foo",
|
|
154
|
+
)
|
|
155
|
+
expect(extractCleanFromEmit("feature-foo-pr", "-pr")).toBe("feature-foo")
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
test("returns null when branch doesn't end with suffix", () => {
|
|
159
|
+
expect(extractCleanFromEmit("feature-foo", "--PR")).toBeNull()
|
|
160
|
+
expect(extractCleanFromEmit("PR-feature-foo", "--PR")).toBeNull()
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test("returns null for empty clean branch", () => {
|
|
164
|
+
expect(extractCleanFromEmit("--PR", "--PR")).toBeNull()
|
|
165
|
+
expect(extractCleanFromEmit("-pr", "-pr")).toBeNull()
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
describe("resolveBranchPairWithAgencyJson", () => {
|
|
171
|
+
let tempDir: string
|
|
172
|
+
|
|
173
|
+
afterEach(async () => {
|
|
174
|
+
if (tempDir) {
|
|
175
|
+
await cleanupTempDir(tempDir)
|
|
176
|
+
}
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
test("uses agency.json emitBranch when on source branch", async () => {
|
|
180
|
+
tempDir = await createTempDir()
|
|
181
|
+
await initGitRepo(tempDir)
|
|
182
|
+
|
|
183
|
+
// Create source branch with agency.json
|
|
184
|
+
await Bun.spawn(["git", "checkout", "-b", "agency/main"], {
|
|
185
|
+
cwd: tempDir,
|
|
186
|
+
stdout: "pipe",
|
|
187
|
+
stderr: "pipe",
|
|
188
|
+
}).exited
|
|
189
|
+
|
|
190
|
+
const agencyJson = {
|
|
191
|
+
version: 1,
|
|
192
|
+
injectedFiles: [],
|
|
193
|
+
template: "test",
|
|
194
|
+
createdAt: new Date().toISOString(),
|
|
195
|
+
emitBranch: "main",
|
|
196
|
+
}
|
|
197
|
+
await Bun.write(join(tempDir, "agency.json"), JSON.stringify(agencyJson))
|
|
198
|
+
|
|
199
|
+
const result = await runTestEffect(
|
|
200
|
+
resolveBranchPairWithAgencyJson(
|
|
201
|
+
tempDir,
|
|
202
|
+
"agency/main",
|
|
203
|
+
"agency/%branch%",
|
|
204
|
+
"%branch%",
|
|
205
|
+
),
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
expect(result.sourceBranch).toBe("agency/main")
|
|
209
|
+
expect(result.emitBranch).toBe("main")
|
|
210
|
+
expect(result.isOnEmitBranch).toBe(false)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
test("finds source branch by searching for matching emitBranch", async () => {
|
|
214
|
+
tempDir = await createTempDir()
|
|
215
|
+
await initGitRepo(tempDir)
|
|
216
|
+
|
|
217
|
+
// Create a source branch with agency.json
|
|
218
|
+
await Bun.spawn(["git", "checkout", "-b", "agency/feature-bar"], {
|
|
219
|
+
cwd: tempDir,
|
|
220
|
+
stdout: "pipe",
|
|
221
|
+
stderr: "pipe",
|
|
222
|
+
}).exited
|
|
223
|
+
|
|
224
|
+
const agencyJson = {
|
|
225
|
+
version: 1,
|
|
226
|
+
injectedFiles: [],
|
|
227
|
+
template: "test",
|
|
228
|
+
createdAt: new Date().toISOString(),
|
|
229
|
+
emitBranch: "feature-bar",
|
|
230
|
+
}
|
|
231
|
+
await Bun.write(join(tempDir, "agency.json"), JSON.stringify(agencyJson))
|
|
232
|
+
await Bun.spawn(["git", "add", "agency.json"], {
|
|
233
|
+
cwd: tempDir,
|
|
234
|
+
stdout: "pipe",
|
|
235
|
+
stderr: "pipe",
|
|
236
|
+
}).exited
|
|
237
|
+
await Bun.spawn(["git", "commit", "-m", "Add agency.json"], {
|
|
238
|
+
cwd: tempDir,
|
|
239
|
+
stdout: "pipe",
|
|
240
|
+
stderr: "pipe",
|
|
241
|
+
}).exited
|
|
242
|
+
|
|
243
|
+
// Create the emit branch from main (so it doesn't have agency.json)
|
|
244
|
+
await Bun.spawn(["git", "checkout", "main"], {
|
|
245
|
+
cwd: tempDir,
|
|
246
|
+
stdout: "pipe",
|
|
247
|
+
stderr: "pipe",
|
|
248
|
+
}).exited
|
|
249
|
+
await Bun.spawn(["git", "checkout", "-b", "feature-bar"], {
|
|
250
|
+
cwd: tempDir,
|
|
251
|
+
stdout: "pipe",
|
|
252
|
+
stderr: "pipe",
|
|
253
|
+
}).exited
|
|
254
|
+
|
|
255
|
+
const result = await runTestEffect(
|
|
256
|
+
resolveBranchPairWithAgencyJson(
|
|
257
|
+
tempDir,
|
|
258
|
+
"feature-bar",
|
|
259
|
+
"agency/%branch%",
|
|
260
|
+
"%branch%",
|
|
261
|
+
),
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
expect(result.sourceBranch).toBe("agency/feature-bar")
|
|
265
|
+
expect(result.emitBranch).toBe("feature-bar")
|
|
266
|
+
expect(result.isOnEmitBranch).toBe(true)
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
test("falls back to pattern-based resolution when agency.json not found", async () => {
|
|
270
|
+
tempDir = await createTempDir()
|
|
271
|
+
await initGitRepo(tempDir)
|
|
272
|
+
|
|
273
|
+
// Create source branch without agency.json
|
|
274
|
+
await Bun.spawn(["git", "checkout", "-b", "agency/feature-baz"], {
|
|
275
|
+
cwd: tempDir,
|
|
276
|
+
stdout: "pipe",
|
|
277
|
+
stderr: "pipe",
|
|
278
|
+
}).exited
|
|
279
|
+
|
|
280
|
+
// No agency.json, should fall back to pattern-based resolution
|
|
281
|
+
const result = await runTestEffect(
|
|
282
|
+
resolveBranchPairWithAgencyJson(
|
|
283
|
+
tempDir,
|
|
284
|
+
"agency/feature-baz",
|
|
285
|
+
"agency/%branch%",
|
|
286
|
+
"%branch%",
|
|
287
|
+
),
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
expect(result.sourceBranch).toBe("agency/feature-baz")
|
|
291
|
+
expect(result.emitBranch).toBe("feature-baz")
|
|
292
|
+
expect(result.isOnEmitBranch).toBe(false)
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
test("treats branch as legacy source when no matching pattern and no source branch exists", async () => {
|
|
296
|
+
tempDir = await createTempDir()
|
|
297
|
+
await initGitRepo(tempDir)
|
|
298
|
+
|
|
299
|
+
// Create a branch without the source pattern (legacy branch)
|
|
300
|
+
// No agency/feature-qux exists, so this is a legacy source branch
|
|
301
|
+
await Bun.spawn(["git", "checkout", "-b", "feature-qux"], {
|
|
302
|
+
cwd: tempDir,
|
|
303
|
+
stdout: "pipe",
|
|
304
|
+
stderr: "pipe",
|
|
305
|
+
}).exited
|
|
306
|
+
|
|
307
|
+
const result = await runTestEffect(
|
|
308
|
+
resolveBranchPairWithAgencyJson(
|
|
309
|
+
tempDir,
|
|
310
|
+
"feature-qux",
|
|
311
|
+
"agency/%branch%",
|
|
312
|
+
"%branch%",
|
|
313
|
+
),
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
// Legacy branch is treated as source, emit is the same name (with %branch% pattern)
|
|
317
|
+
expect(result.sourceBranch).toBe("feature-qux")
|
|
318
|
+
expect(result.emitBranch).toBe("feature-qux")
|
|
319
|
+
expect(result.isOnEmitBranch).toBe(false)
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
test("handles branches with no agency.json on current branch", async () => {
|
|
323
|
+
tempDir = await createTempDir()
|
|
324
|
+
await initGitRepo(tempDir)
|
|
325
|
+
|
|
326
|
+
// Create source branch, no agency.json
|
|
327
|
+
await Bun.spawn(["git", "checkout", "-b", "agency/main"], {
|
|
328
|
+
cwd: tempDir,
|
|
329
|
+
stdout: "pipe",
|
|
330
|
+
stderr: "pipe",
|
|
331
|
+
}).exited
|
|
332
|
+
|
|
333
|
+
// Just test pattern-based resolution with no agency.json anywhere
|
|
334
|
+
const result = await runTestEffect(
|
|
335
|
+
resolveBranchPairWithAgencyJson(
|
|
336
|
+
tempDir,
|
|
337
|
+
"agency/main",
|
|
338
|
+
"agency/%branch%",
|
|
339
|
+
"%branch%",
|
|
340
|
+
),
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
expect(result.sourceBranch).toBe("agency/main")
|
|
344
|
+
expect(result.emitBranch).toBe("main")
|
|
345
|
+
expect(result.isOnEmitBranch).toBe(false)
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
test("handles emit pattern with suffix", async () => {
|
|
349
|
+
tempDir = await createTempDir()
|
|
350
|
+
await initGitRepo(tempDir)
|
|
351
|
+
|
|
352
|
+
// Test with custom emit pattern that adds a suffix
|
|
353
|
+
await Bun.spawn(["git", "checkout", "-b", "agency/feature"], {
|
|
354
|
+
cwd: tempDir,
|
|
355
|
+
stdout: "pipe",
|
|
356
|
+
stderr: "pipe",
|
|
357
|
+
}).exited
|
|
358
|
+
|
|
359
|
+
const result = await runTestEffect(
|
|
360
|
+
resolveBranchPairWithAgencyJson(
|
|
361
|
+
tempDir,
|
|
362
|
+
"agency/feature",
|
|
363
|
+
"agency/%branch%",
|
|
364
|
+
"%branch%--PR",
|
|
365
|
+
),
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
expect(result.sourceBranch).toBe("agency/feature")
|
|
369
|
+
expect(result.emitBranch).toBe("feature--PR")
|
|
370
|
+
expect(result.isOnEmitBranch).toBe(false)
|
|
371
|
+
})
|
|
372
|
+
})
|