@markjaquith/agency 1.2.0 → 1.3.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/cli.ts +2 -0
- package/package.json +4 -2
- package/src/commands/clean.ts +4 -33
- package/src/commands/emit.integration.test.ts +277 -0
- package/src/commands/emit.test.ts +53 -208
- package/src/commands/emit.ts +5 -33
- package/src/commands/merge.integration.test.ts +195 -0
- package/src/commands/merge.test.ts +0 -119
- package/src/commands/merge.ts +3 -12
- package/src/commands/push.ts +4 -15
- package/src/commands/rebase.ts +3 -11
- package/src/commands/status.ts +3 -7
- package/src/commands/tasks.ts +4 -21
- package/src/services/AgencyMetadataService.ts +3 -7
- package/src/services/FilterRepoService.ts +142 -0
- package/src/services/GitService.ts +196 -0
- package/src/services/MockFilterRepoService.ts +133 -0
- package/src/test-utils.ts +85 -2
- package/src/utils/pr-branch.ts +6 -14
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { Effect, Layer } from "effect"
|
|
2
|
+
import { FilterRepoService } from "./FilterRepoService"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Captured filter-repo call for verification in tests.
|
|
6
|
+
*/
|
|
7
|
+
export interface CapturedFilterRepoCall {
|
|
8
|
+
gitRoot: string
|
|
9
|
+
args: readonly string[]
|
|
10
|
+
env?: Record<string, string>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Global state for captured filter-repo calls.
|
|
15
|
+
* Tests can inspect this to verify correct commands were constructed.
|
|
16
|
+
*/
|
|
17
|
+
let capturedCalls: CapturedFilterRepoCall[] = []
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Clear all captured filter-repo calls.
|
|
21
|
+
* Call this in beforeEach() to reset state between tests.
|
|
22
|
+
*/
|
|
23
|
+
export function clearCapturedFilterRepoCalls(): void {
|
|
24
|
+
capturedCalls = []
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Get all captured filter-repo calls.
|
|
29
|
+
* @returns Array of captured calls
|
|
30
|
+
*/
|
|
31
|
+
export function getCapturedFilterRepoCalls(): readonly CapturedFilterRepoCall[] {
|
|
32
|
+
return capturedCalls
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Get the last captured filter-repo call.
|
|
37
|
+
* @returns The last captured call, or undefined if none
|
|
38
|
+
*/
|
|
39
|
+
export function getLastCapturedFilterRepoCall():
|
|
40
|
+
| CapturedFilterRepoCall
|
|
41
|
+
| undefined {
|
|
42
|
+
return capturedCalls[capturedCalls.length - 1]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Mock implementation of FilterRepoService.
|
|
47
|
+
*
|
|
48
|
+
* This mock:
|
|
49
|
+
* - Always returns true for isInstalled()
|
|
50
|
+
* - Captures the arguments passed to run() without executing
|
|
51
|
+
* - Returns a successful result with exit code 0
|
|
52
|
+
*
|
|
53
|
+
* Use getCapturedFilterRepoCalls() to verify the correct commands were constructed.
|
|
54
|
+
*/
|
|
55
|
+
export class MockFilterRepoService extends Effect.Service<MockFilterRepoService>()(
|
|
56
|
+
"FilterRepoService", // Same tag as the real service to replace it
|
|
57
|
+
{
|
|
58
|
+
sync: () => ({
|
|
59
|
+
isInstalled: () => Effect.succeed(true),
|
|
60
|
+
|
|
61
|
+
run: (
|
|
62
|
+
gitRoot: string,
|
|
63
|
+
args: readonly string[],
|
|
64
|
+
options?: {
|
|
65
|
+
readonly env?: Record<string, string>
|
|
66
|
+
},
|
|
67
|
+
) => {
|
|
68
|
+
// Capture the call
|
|
69
|
+
capturedCalls.push({
|
|
70
|
+
gitRoot,
|
|
71
|
+
args,
|
|
72
|
+
env: options?.env,
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
// Return a successful result
|
|
76
|
+
return Effect.succeed({
|
|
77
|
+
exitCode: 0,
|
|
78
|
+
stdout: "",
|
|
79
|
+
stderr: "",
|
|
80
|
+
})
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
filterFiles: (
|
|
84
|
+
gitRoot: string,
|
|
85
|
+
options: {
|
|
86
|
+
readonly refs?: string
|
|
87
|
+
readonly pathsToRemove?: readonly string[]
|
|
88
|
+
readonly pathRenames?: readonly { from: string; to: string }[]
|
|
89
|
+
readonly force?: boolean
|
|
90
|
+
},
|
|
91
|
+
) => {
|
|
92
|
+
// Build the args like the real implementation would
|
|
93
|
+
const args: string[] = []
|
|
94
|
+
|
|
95
|
+
if (options.refs) {
|
|
96
|
+
args.push("--refs", options.refs)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (options.pathsToRemove) {
|
|
100
|
+
for (const path of options.pathsToRemove) {
|
|
101
|
+
args.push("--invert-paths", "--path", path)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (options.pathRenames) {
|
|
106
|
+
for (const rename of options.pathRenames) {
|
|
107
|
+
args.push("--path-rename", `${rename.from}:${rename.to}`)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (options.force) {
|
|
112
|
+
args.push("--force")
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
args.push("--prune-empty=always")
|
|
116
|
+
|
|
117
|
+
// Capture the call
|
|
118
|
+
capturedCalls.push({
|
|
119
|
+
gitRoot,
|
|
120
|
+
args,
|
|
121
|
+
env: { GIT_CONFIG_GLOBAL: "" },
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
// Return a successful result
|
|
125
|
+
return Effect.succeed({
|
|
126
|
+
exitCode: 0,
|
|
127
|
+
stdout: "",
|
|
128
|
+
stderr: "",
|
|
129
|
+
})
|
|
130
|
+
},
|
|
131
|
+
}),
|
|
132
|
+
},
|
|
133
|
+
) {}
|
package/src/test-utils.ts
CHANGED
|
@@ -235,6 +235,47 @@ export async function deleteBranch(
|
|
|
235
235
|
await gitRun(cwd, ["branch", flag, branchName])
|
|
236
236
|
}
|
|
237
237
|
|
|
238
|
+
/**
|
|
239
|
+
* Reset a git repo to clean state for test reuse.
|
|
240
|
+
* This is much faster than creating a new repo from scratch.
|
|
241
|
+
* - Checks out main branch
|
|
242
|
+
* - Removes all other branches
|
|
243
|
+
* - Resets to initial commit
|
|
244
|
+
* - Cleans untracked files
|
|
245
|
+
* - Removes agency git config
|
|
246
|
+
*/
|
|
247
|
+
export async function resetGitRepo(cwd: string): Promise<void> {
|
|
248
|
+
// Checkout main
|
|
249
|
+
await gitRun(cwd, ["checkout", "-q", "main"])
|
|
250
|
+
|
|
251
|
+
// Delete all branches except main
|
|
252
|
+
const branchOutput = await getGitOutput(cwd, ["branch", "--list"])
|
|
253
|
+
const branches = branchOutput
|
|
254
|
+
.split("\n")
|
|
255
|
+
.map((b) => b.replace(/^\*?\s*/, "").trim())
|
|
256
|
+
.filter((b) => b && b !== "main")
|
|
257
|
+
|
|
258
|
+
for (const branch of branches) {
|
|
259
|
+
await gitRun(cwd, ["branch", "-D", branch])
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Reset to first commit (the initial commit from template)
|
|
263
|
+
const firstCommit = (
|
|
264
|
+
await getGitOutput(cwd, ["rev-list", "--max-parents=0", "HEAD"])
|
|
265
|
+
).trim()
|
|
266
|
+
await gitRun(cwd, ["reset", "--hard", firstCommit])
|
|
267
|
+
|
|
268
|
+
// Clean untracked files and directories
|
|
269
|
+
await gitRun(cwd, ["clean", "-fdx"])
|
|
270
|
+
|
|
271
|
+
// Remove agency config
|
|
272
|
+
try {
|
|
273
|
+
await gitRun(cwd, ["config", "--unset", "agency.template"])
|
|
274
|
+
} catch {
|
|
275
|
+
// Ignore if not set
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
238
279
|
/**
|
|
239
280
|
* Rename current branch
|
|
240
281
|
*/
|
|
@@ -340,8 +381,18 @@ import { PromptService } from "./services/PromptService"
|
|
|
340
381
|
import { TemplateService } from "./services/TemplateService"
|
|
341
382
|
import { OpencodeService } from "./services/OpencodeService"
|
|
342
383
|
import { ClaudeService } from "./services/ClaudeService"
|
|
343
|
-
|
|
344
|
-
|
|
384
|
+
import { FilterRepoService } from "./services/FilterRepoService"
|
|
385
|
+
import { MockFilterRepoService } from "./services/MockFilterRepoService"
|
|
386
|
+
|
|
387
|
+
// Re-export mock utilities for tests
|
|
388
|
+
export {
|
|
389
|
+
clearCapturedFilterRepoCalls,
|
|
390
|
+
getCapturedFilterRepoCalls,
|
|
391
|
+
getLastCapturedFilterRepoCall,
|
|
392
|
+
} from "./services/MockFilterRepoService"
|
|
393
|
+
export type { CapturedFilterRepoCall } from "./services/MockFilterRepoService"
|
|
394
|
+
|
|
395
|
+
// Create test layer with all services (real filter-repo)
|
|
345
396
|
const TestLayer = Layer.mergeAll(
|
|
346
397
|
GitService.Default,
|
|
347
398
|
ConfigService.Default,
|
|
@@ -350,6 +401,19 @@ const TestLayer = Layer.mergeAll(
|
|
|
350
401
|
TemplateService.Default,
|
|
351
402
|
OpencodeService.Default,
|
|
352
403
|
ClaudeService.Default,
|
|
404
|
+
FilterRepoService.Default,
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
// Create test layer with mock filter-repo (for tests that don't need real filtering)
|
|
408
|
+
const TestLayerWithMockFilterRepo = Layer.mergeAll(
|
|
409
|
+
GitService.Default,
|
|
410
|
+
ConfigService.Default,
|
|
411
|
+
FileSystemService.Default,
|
|
412
|
+
PromptService.Default,
|
|
413
|
+
TemplateService.Default,
|
|
414
|
+
OpencodeService.Default,
|
|
415
|
+
ClaudeService.Default,
|
|
416
|
+
MockFilterRepoService.Default,
|
|
353
417
|
)
|
|
354
418
|
|
|
355
419
|
export async function runTestEffect<A, E>(
|
|
@@ -366,3 +430,22 @@ export async function runTestEffect<A, E>(
|
|
|
366
430
|
|
|
367
431
|
return await Effect.runPromise(program)
|
|
368
432
|
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Run a test effect with MockFilterRepoService instead of the real one.
|
|
436
|
+
* Use this for tests that need to verify filter-repo command construction
|
|
437
|
+
* without actually running git-filter-repo.
|
|
438
|
+
*/
|
|
439
|
+
export async function runTestEffectWithMockFilterRepo<A, E>(
|
|
440
|
+
effect: Effect.Effect<A, E, any>,
|
|
441
|
+
): Promise<A> {
|
|
442
|
+
const providedEffect = Effect.provide(
|
|
443
|
+
effect,
|
|
444
|
+
TestLayerWithMockFilterRepo,
|
|
445
|
+
) as Effect.Effect<A, E, never>
|
|
446
|
+
const program = Effect.catchAllDefect(providedEffect, (defect) =>
|
|
447
|
+
Effect.fail(defect instanceof Error ? defect : new Error(String(defect))),
|
|
448
|
+
) as Effect.Effect<A, E | Error, never>
|
|
449
|
+
|
|
450
|
+
return await Effect.runPromise(program)
|
|
451
|
+
}
|
package/src/utils/pr-branch.ts
CHANGED
|
@@ -278,26 +278,18 @@ const findSourceBranchByEmitBranch = (
|
|
|
278
278
|
const git = yield* GitService
|
|
279
279
|
|
|
280
280
|
// Get all local branches
|
|
281
|
-
const
|
|
282
|
-
git.
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
{
|
|
286
|
-
captureOutput: true,
|
|
287
|
-
},
|
|
281
|
+
const branches = yield* pipe(
|
|
282
|
+
git.getAllLocalBranches(gitRoot),
|
|
283
|
+
Effect.map((allBranches) =>
|
|
284
|
+
allBranches.filter((b) => b !== currentBranch),
|
|
288
285
|
),
|
|
289
|
-
Effect.catchAll(() => Effect.succeed(
|
|
286
|
+
Effect.catchAll(() => Effect.succeed([] as readonly string[])),
|
|
290
287
|
)
|
|
291
288
|
|
|
292
|
-
if (
|
|
289
|
+
if (branches.length === 0) {
|
|
293
290
|
return null
|
|
294
291
|
}
|
|
295
292
|
|
|
296
|
-
const branches = branchesResult.stdout
|
|
297
|
-
.split("\n")
|
|
298
|
-
.map((b) => b.trim())
|
|
299
|
-
.filter((b) => b.length > 0 && b !== currentBranch)
|
|
300
|
-
|
|
301
293
|
// Search each branch for agency.json with matching emitBranch
|
|
302
294
|
for (const branch of branches) {
|
|
303
295
|
const metadata = yield* readAgencyJsonFromBranch(gitRoot, branch)
|