@markjaquith/agency 1.1.1 → 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.
@@ -874,5 +874,201 @@ export class GitService extends Effect.Service<GitService>()("GitService", {
874
874
  ),
875
875
  Effect.catchAll(() => Effect.succeed(false)),
876
876
  ),
877
+
878
+ /**
879
+ * Get all local branch names.
880
+ * @param gitRoot - The git repository root
881
+ * @returns Array of local branch names
882
+ */
883
+ getAllLocalBranches: (gitRoot: string) =>
884
+ pipe(
885
+ runGitCommand(["git", "branch", "--format=%(refname:short)"], gitRoot),
886
+ Effect.map((result) => {
887
+ if (result.exitCode === 0 && result.stdout.trim()) {
888
+ return result.stdout.trim().split("\n") as readonly string[]
889
+ }
890
+ return [] as readonly string[]
891
+ }),
892
+ Effect.mapError(
893
+ () => new GitError({ message: "Failed to get local branches" }),
894
+ ),
895
+ ),
896
+
897
+ /**
898
+ * Get branches that have been merged into a target branch.
899
+ * @param gitRoot - The git repository root
900
+ * @param targetBranch - The branch to check merges against
901
+ * @returns Array of merged branch names
902
+ */
903
+ getMergedBranches: (gitRoot: string, targetBranch: string) =>
904
+ pipe(
905
+ runGitCommand(
906
+ [
907
+ "git",
908
+ "branch",
909
+ "--merged",
910
+ targetBranch,
911
+ "--format=%(refname:short)",
912
+ ],
913
+ gitRoot,
914
+ ),
915
+ Effect.map((result) => {
916
+ if (result.exitCode === 0 && result.stdout.trim()) {
917
+ return result.stdout.trim().split("\n") as readonly string[]
918
+ }
919
+ return [] as readonly string[]
920
+ }),
921
+ Effect.mapError(
922
+ () =>
923
+ new GitError({
924
+ message: `Failed to get branches merged into ${targetBranch}`,
925
+ }),
926
+ ),
927
+ ),
928
+
929
+ /**
930
+ * Push a branch to a remote.
931
+ * @param gitRoot - The git repository root
932
+ * @param remote - Remote name (e.g., "origin")
933
+ * @param branch - Branch name to push
934
+ * @param options - Push options
935
+ * @returns Object with exitCode, stdout, stderr
936
+ */
937
+ push: (
938
+ gitRoot: string,
939
+ remote: string,
940
+ branch: string,
941
+ options?: {
942
+ readonly setUpstream?: boolean
943
+ readonly force?: boolean
944
+ },
945
+ ) => {
946
+ const args = ["git", "push"]
947
+ if (options?.setUpstream) {
948
+ args.push("-u")
949
+ }
950
+ if (options?.force) {
951
+ args.push("--force")
952
+ }
953
+ args.push(remote, branch)
954
+
955
+ return pipe(
956
+ runGitCommand(args, gitRoot),
957
+ Effect.mapError(
958
+ (error) =>
959
+ new GitError({
960
+ message: `Failed to push ${branch} to ${remote}`,
961
+ cause: error,
962
+ }),
963
+ ),
964
+ )
965
+ },
966
+
967
+ /**
968
+ * Merge a branch into the current branch.
969
+ * @param gitRoot - The git repository root
970
+ * @param branch - Branch to merge
971
+ * @param options - Merge options
972
+ * @returns Object with exitCode, stdout, stderr
973
+ */
974
+ merge: (
975
+ gitRoot: string,
976
+ branch: string,
977
+ options?: {
978
+ readonly squash?: boolean
979
+ readonly noCommit?: boolean
980
+ readonly message?: string
981
+ },
982
+ ) => {
983
+ const args = ["git", "merge"]
984
+ if (options?.squash) {
985
+ args.push("--squash")
986
+ }
987
+ if (options?.noCommit) {
988
+ args.push("--no-commit")
989
+ }
990
+ if (options?.message) {
991
+ args.push("-m", options.message)
992
+ }
993
+ args.push(branch)
994
+
995
+ return pipe(
996
+ runGitCommand(args, gitRoot),
997
+ Effect.mapError(
998
+ (error) =>
999
+ new GitError({
1000
+ message: `Failed to merge ${branch}`,
1001
+ cause: error,
1002
+ }),
1003
+ ),
1004
+ )
1005
+ },
1006
+
1007
+ /**
1008
+ * Rebase current branch onto a base branch.
1009
+ * @param gitRoot - The git repository root
1010
+ * @param baseBranch - Branch to rebase onto
1011
+ * @returns Object with exitCode, stdout, stderr
1012
+ */
1013
+ rebase: (gitRoot: string, baseBranch: string) =>
1014
+ pipe(
1015
+ runGitCommand(["git", "rebase", baseBranch], gitRoot),
1016
+ Effect.mapError(
1017
+ (error) =>
1018
+ new GitError({
1019
+ message: `Failed to rebase onto ${baseBranch}`,
1020
+ cause: error,
1021
+ }),
1022
+ ),
1023
+ ),
1024
+
1025
+ /**
1026
+ * Get the working tree status (porcelain format).
1027
+ * @param gitRoot - The git repository root
1028
+ * @returns Status output string
1029
+ */
1030
+ getStatus: (gitRoot: string) =>
1031
+ pipe(
1032
+ runGitCommand(["git", "status", "--porcelain"], gitRoot),
1033
+ Effect.map((result) => result.stdout),
1034
+ Effect.mapError(
1035
+ () => new GitError({ message: "Failed to get git status" }),
1036
+ ),
1037
+ ),
1038
+
1039
+ /**
1040
+ * Check if one commit is an ancestor of another.
1041
+ * @param gitRoot - The git repository root
1042
+ * @param potentialAncestor - The commit that might be an ancestor
1043
+ * @param commit - The commit to check against
1044
+ * @returns true if potentialAncestor is an ancestor of commit
1045
+ */
1046
+ isAncestor: (gitRoot: string, potentialAncestor: string, commit: string) =>
1047
+ pipe(
1048
+ runGitCommand(
1049
+ ["git", "merge-base", "--is-ancestor", potentialAncestor, commit],
1050
+ gitRoot,
1051
+ ),
1052
+ Effect.map((result) => result.exitCode === 0),
1053
+ Effect.catchAll(() => Effect.succeed(false)),
1054
+ ),
1055
+
1056
+ /**
1057
+ * Create or reset a branch to point to a specific commit/branch.
1058
+ * Uses `git checkout -B` which creates the branch if it doesn't exist
1059
+ * or resets it if it does.
1060
+ * @param gitRoot - The git repository root
1061
+ * @param branchName - Name of the branch to create/reset
1062
+ * @param startPoint - Commit or branch to start from
1063
+ */
1064
+ createOrResetBranch: (
1065
+ gitRoot: string,
1066
+ branchName: string,
1067
+ startPoint: string,
1068
+ ) =>
1069
+ runGitCommandVoid(
1070
+ ["git", "checkout", "-q", "-B", branchName, startPoint],
1071
+ gitRoot,
1072
+ ),
877
1073
  }),
878
1074
  }) {}
@@ -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)