@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.
@@ -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
- // Create test layer with all services
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
+ }
@@ -278,26 +278,18 @@ const findSourceBranchByEmitBranch = (
278
278
  const git = yield* GitService
279
279
 
280
280
  // Get all local branches
281
- const branchesResult = yield* pipe(
282
- git.runGitCommand(
283
- ["git", "branch", "--format=%(refname:short)"],
284
- gitRoot,
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({ exitCode: 1, stdout: "" })),
286
+ Effect.catchAll(() => Effect.succeed([] as readonly string[])),
290
287
  )
291
288
 
292
- if (branchesResult.exitCode !== 0 || !branchesResult.stdout) {
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)