@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
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.
|
|
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",
|
package/src/commands/clean.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
})
|