@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 CHANGED
@@ -29,6 +29,7 @@ import { PromptService } from "./src/services/PromptService"
29
29
  import { TemplateService } from "./src/services/TemplateService"
30
30
  import { OpencodeService } from "./src/services/OpencodeService"
31
31
  import { ClaudeService } from "./src/services/ClaudeService"
32
+ import { FilterRepoService } from "./src/services/FilterRepoService"
32
33
 
33
34
  // Create CLI layer with all services
34
35
  const CliLayer = Layer.mergeAll(
@@ -39,6 +40,7 @@ const CliLayer = Layer.mergeAll(
39
40
  TemplateService.Default,
40
41
  OpencodeService.Default,
41
42
  ClaudeService.Default,
43
+ FilterRepoService.Default,
42
44
  )
43
45
 
44
46
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Manages personal agents files",
5
5
  "license": "MIT",
6
6
  "author": "Mark Jaquith",
@@ -14,7 +14,9 @@
14
14
  "agency": "cli.ts"
15
15
  },
16
16
  "scripts": {
17
- "test": "find src -name '*.test.ts' -print0 | xargs -0 -n 1 -P 4 bun test",
17
+ "test": "find src -name '*.test.ts' ! -name '*.integration.test.ts' -print0 | xargs -0 -n 1 -P 4 bun test",
18
+ "test:integration": "find src -name '*.integration.test.ts' -print0 | xargs -0 -n 1 -P 4 bun test",
19
+ "test:all": "find src -name '*.test.ts' -print0 | xargs -0 -n 1 -P 4 bun test",
18
20
  "format": "prettier --write .",
19
21
  "format:check": "prettier --check .",
20
22
  "knip": "knip --production",
@@ -53,23 +53,8 @@ const readAgencyMetadata = (gitRoot: string, branch: string) =>
53
53
  const getAllLocalBranches = (gitRoot: string) =>
54
54
  Effect.gen(function* () {
55
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([])))
56
+ return yield* git.getAllLocalBranches(gitRoot)
57
+ }).pipe(Effect.catchAll(() => Effect.succeed([] as readonly string[])))
73
58
 
74
59
  /**
75
60
  * Get all branches that have been fully merged into the specified branch
@@ -87,25 +72,11 @@ const getBranchesMergedInto = (
87
72
  `Finding branches merged into ${highlight.branch(targetBranch)}...`,
88
73
  )
89
74
 
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)
75
+ const mergedBranches = yield* git.getMergedBranches(gitRoot, targetBranch)
105
76
 
106
77
  verboseLog(`Found ${mergedBranches.length} merged branches`)
107
78
  return mergedBranches
108
- }).pipe(Effect.catchAll(() => Effect.succeed([])))
79
+ }).pipe(Effect.catchAll(() => Effect.succeed([] as readonly string[])))
109
80
 
110
81
  /**
111
82
  * Find source branches for the given branches (emit branches).
@@ -0,0 +1,277 @@
1
+ import { test, expect, describe, beforeEach, afterEach } from "bun:test"
2
+ import { join } from "path"
3
+ import { emit } from "../commands/emit"
4
+ import { task } from "../commands/task"
5
+ import {
6
+ createTempDir,
7
+ cleanupTempDir,
8
+ initGitRepo,
9
+ initAgency,
10
+ getGitOutput,
11
+ getCurrentBranch,
12
+ createCommit,
13
+ checkoutBranch,
14
+ createBranch,
15
+ addAndCommit,
16
+ setupRemote,
17
+ runTestEffect,
18
+ } from "../test-utils"
19
+
20
+ // Cache the git-filter-repo availability check (it doesn't change during test run)
21
+ let hasGitFilterRepoCache: boolean | null = null
22
+ async function checkGitFilterRepo(): Promise<boolean> {
23
+ if (hasGitFilterRepoCache === null) {
24
+ const proc = Bun.spawn(["which", "git-filter-repo"], {
25
+ stdout: "pipe",
26
+ stderr: "pipe",
27
+ })
28
+ await proc.exited
29
+ hasGitFilterRepoCache = proc.exitCode === 0
30
+ }
31
+ return hasGitFilterRepoCache
32
+ }
33
+
34
+ describe("emit command - integration tests (requires git-filter-repo)", () => {
35
+ let tempDir: string
36
+ let originalCwd: string
37
+ let hasGitFilterRepo: boolean
38
+
39
+ beforeEach(async () => {
40
+ tempDir = await createTempDir()
41
+ originalCwd = process.cwd()
42
+ process.chdir(tempDir)
43
+
44
+ // Set config path to non-existent file to use defaults
45
+ process.env.AGENCY_CONFIG_PATH = join(tempDir, "non-existent-config.json")
46
+ // Set config dir to temp dir to avoid picking up user's config files
47
+ process.env.AGENCY_CONFIG_DIR = await createTempDir()
48
+
49
+ // Check if git-filter-repo is available (cached)
50
+ hasGitFilterRepo = await checkGitFilterRepo()
51
+
52
+ // Initialize git repo with main branch (already includes initial commit)
53
+ await initGitRepo(tempDir)
54
+
55
+ // Create a source branch (with agency/ prefix)
56
+ await createBranch(tempDir, "agency/test-feature")
57
+
58
+ // Initialize AGENTS.md and commit in one go
59
+ await initAgency(tempDir, "test")
60
+
61
+ await runTestEffect(task({ silent: true, fromCurrent: true }))
62
+ await addAndCommit(tempDir, "AGENTS.md", "Add AGENTS.md")
63
+
64
+ // Set up origin/main for git-filter-repo
65
+ await setupRemote(tempDir, "origin", tempDir)
66
+ })
67
+
68
+ afterEach(async () => {
69
+ process.chdir(originalCwd)
70
+ delete process.env.AGENCY_CONFIG_PATH
71
+ if (process.env.AGENCY_CONFIG_DIR) {
72
+ await cleanupTempDir(process.env.AGENCY_CONFIG_DIR)
73
+ delete process.env.AGENCY_CONFIG_DIR
74
+ }
75
+ await cleanupTempDir(tempDir)
76
+ })
77
+
78
+ test("filters AGENTS.md from emit branch", async () => {
79
+ if (!hasGitFilterRepo) {
80
+ console.log("Skipping test: git-filter-repo not installed")
81
+ return
82
+ }
83
+
84
+ // Go back to main and create a fresh source branch
85
+ await checkoutBranch(tempDir, "main")
86
+ await createBranch(tempDir, "agency/feature")
87
+ // Create agency.json with AGENTS.md as managed file
88
+ await Bun.write(
89
+ join(tempDir, "agency.json"),
90
+ JSON.stringify({
91
+ version: 1,
92
+ injectedFiles: ["AGENTS.md"],
93
+ template: "test",
94
+ createdAt: new Date().toISOString(),
95
+ }),
96
+ )
97
+ await Bun.write(join(tempDir, "AGENTS.md"), "# Test AGENTS\n")
98
+ await addAndCommit(tempDir, "agency.json AGENTS.md", "Add agency files")
99
+ // Also create a test.txt file via createCommit
100
+ await createCommit(tempDir, "Feature commit")
101
+
102
+ // Create emit branch (this runs git-filter-repo)
103
+ await runTestEffect(emit({ silent: true }))
104
+
105
+ // Should still be on feature branch
106
+ const currentBranch = await getCurrentBranch(tempDir)
107
+ expect(currentBranch).toBe("agency/feature")
108
+
109
+ // Switch to emit branch to verify files
110
+ await checkoutBranch(tempDir, "feature")
111
+
112
+ // AGENTS.md should be filtered out
113
+ const files = await getGitOutput(tempDir, ["ls-files"])
114
+ expect(files).not.toContain("AGENTS.md")
115
+ expect(files).not.toContain("AGENCY.md")
116
+
117
+ // But test.txt should still exist
118
+ expect(files).toContain("test.txt")
119
+ })
120
+
121
+ test("handles emit branch recreation after source branch rebase", async () => {
122
+ if (!hasGitFilterRepo) {
123
+ console.log("Skipping test: git-filter-repo not installed")
124
+ return
125
+ }
126
+
127
+ // We're on test-feature which has AGENTS.md
128
+ // Add a feature-specific file to avoid conflicts
129
+ await Bun.write(join(tempDir, "feature.txt"), "feature content\n")
130
+ await addAndCommit(tempDir, "feature.txt", "Add feature file")
131
+
132
+ // Store merge-base before advancing main
133
+ const initialMergeBase = await getGitOutput(tempDir, [
134
+ "merge-base",
135
+ "agency/test-feature",
136
+ "main",
137
+ ])
138
+
139
+ // Create initial emit branch
140
+ await runTestEffect(emit({ silent: true, baseBranch: "main" }))
141
+
142
+ // Should still be on test-feature branch
143
+ let currentBranch = await getCurrentBranch(tempDir)
144
+ expect(currentBranch).toBe("agency/test-feature")
145
+
146
+ // Switch to emit branch to verify AGENTS.md is filtered
147
+ await checkoutBranch(tempDir, "test-feature")
148
+
149
+ let files = await getGitOutput(tempDir, ["ls-files"])
150
+ expect(files).not.toContain("AGENTS.md")
151
+ expect(files).toContain("feature.txt")
152
+
153
+ // Switch back to source branch
154
+ await checkoutBranch(tempDir, "agency/test-feature")
155
+
156
+ // Simulate advancing main branch with a different file
157
+ await checkoutBranch(tempDir, "main")
158
+ await Bun.write(join(tempDir, "main-file.txt"), "main content\n")
159
+ await addAndCommit(tempDir, "main-file.txt", "Main branch advancement")
160
+
161
+ // Rebase test-feature onto new main
162
+ await checkoutBranch(tempDir, "agency/test-feature")
163
+ const rebaseProc = Bun.spawn(["git", "rebase", "main"], {
164
+ cwd: tempDir,
165
+ stdout: "pipe",
166
+ stderr: "pipe",
167
+ })
168
+ await rebaseProc.exited
169
+ if (rebaseProc.exitCode !== 0) {
170
+ const stderr = await new Response(rebaseProc.stderr).text()
171
+ throw new Error(`Rebase failed: ${stderr}`)
172
+ }
173
+
174
+ // Verify merge-base has changed after rebase
175
+ const newMergeBase = await getGitOutput(tempDir, [
176
+ "merge-base",
177
+ "agency/test-feature",
178
+ "main",
179
+ ])
180
+ expect(newMergeBase.trim()).not.toBe(initialMergeBase.trim())
181
+
182
+ // Recreate emit branch after rebase (this is where the bug would manifest)
183
+ await runTestEffect(emit({ silent: true, baseBranch: "main" }))
184
+
185
+ // Should still be on test-feature branch
186
+ currentBranch = await getCurrentBranch(tempDir)
187
+ expect(currentBranch).toBe("agency/test-feature")
188
+
189
+ // Switch to emit branch to verify files
190
+ await checkoutBranch(tempDir, "test-feature")
191
+
192
+ // Verify AGENTS.md is still filtered and no extraneous changes
193
+ files = await getGitOutput(tempDir, ["ls-files"])
194
+ expect(files).not.toContain("AGENTS.md")
195
+ expect(files).toContain("feature.txt")
196
+ expect(files).toContain("main-file.txt") // Should have main's file after rebase
197
+
198
+ // Verify that our feature commits exist but AGENTS.md commit is filtered
199
+ const logOutput = await getGitOutput(tempDir, [
200
+ "log",
201
+ "--oneline",
202
+ "main..test-feature",
203
+ ])
204
+ expect(logOutput).toContain("Add feature file")
205
+ expect(logOutput).not.toContain("Add AGENTS.md")
206
+ })
207
+
208
+ test("filters pre-existing CLAUDE.md that gets edited by agency", async () => {
209
+ if (!hasGitFilterRepo) {
210
+ console.log("Skipping test: git-filter-repo not installed")
211
+ return
212
+ }
213
+
214
+ // Start fresh on main branch
215
+ await checkoutBranch(tempDir, "main")
216
+
217
+ // Create CLAUDE.md on main branch (simulating pre-existing file)
218
+ await Bun.write(
219
+ join(tempDir, "CLAUDE.md"),
220
+ "# Original Claude Instructions\n\nSome content here.\n",
221
+ )
222
+ await addAndCommit(tempDir, "CLAUDE.md", "Add CLAUDE.md")
223
+
224
+ // Create a new feature branch
225
+ await createBranch(tempDir, "agency/claude-test")
226
+
227
+ // Initialize agency on this branch (this will modify CLAUDE.md)
228
+ await Bun.write(
229
+ join(tempDir, "agency.json"),
230
+ JSON.stringify({
231
+ version: 1,
232
+ injectedFiles: ["AGENTS.md"],
233
+ template: "test",
234
+ createdAt: new Date().toISOString(),
235
+ }),
236
+ )
237
+ await Bun.write(join(tempDir, "AGENTS.md"), "# Test AGENTS\n")
238
+
239
+ // Simulate what agency task does - inject into CLAUDE.md
240
+ const originalClaude = await Bun.file(join(tempDir, "CLAUDE.md")).text()
241
+ const modifiedClaude = `${originalClaude}\n# Agency References\n@AGENTS.md\n@TASK.md\n`
242
+ await Bun.write(join(tempDir, "CLAUDE.md"), modifiedClaude)
243
+
244
+ await addAndCommit(
245
+ tempDir,
246
+ "agency.json AGENTS.md CLAUDE.md",
247
+ "Initialize agency files",
248
+ )
249
+
250
+ // Add a feature file
251
+ await createCommit(tempDir, "Feature commit")
252
+
253
+ // Create emit branch (this should filter CLAUDE.md)
254
+ await runTestEffect(emit({ silent: true, baseBranch: "main" }))
255
+
256
+ // Should still be on source branch
257
+ const currentBranch = await getCurrentBranch(tempDir)
258
+ expect(currentBranch).toBe("agency/claude-test")
259
+
260
+ // Switch to emit branch to verify CLAUDE.md is reverted to main's version
261
+ await checkoutBranch(tempDir, "claude-test")
262
+
263
+ const files = await getGitOutput(tempDir, ["ls-files"])
264
+ expect(files).toContain("CLAUDE.md") // File should exist (from main)
265
+ expect(files).not.toContain("AGENTS.md") // Should be filtered
266
+ expect(files).not.toContain("TASK.md") // Should be filtered
267
+ expect(files).toContain("test.txt") // Feature file should exist
268
+
269
+ // Verify CLAUDE.md was reverted to original (no agency references)
270
+ const claudeContent = await Bun.file(join(tempDir, "CLAUDE.md")).text()
271
+ expect(claudeContent).toBe(
272
+ "# Original Claude Instructions\n\nSome content here.\n",
273
+ )
274
+ expect(claudeContent).not.toContain("@AGENTS.md")
275
+ expect(claudeContent).not.toContain("@TASK.md")
276
+ })
277
+ })