@markjaquith/agency 1.8.7 → 1.9.1
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 +4 -0
- package/package.json +1 -1
- package/src/commands/emit.test.ts +31 -0
- package/src/commands/emit.ts +82 -3
- package/src/commands/push.ts +18 -9
- package/src/services/FilterRepoService.ts +10 -0
- package/src/services/GitService.ts +4 -0
- package/src/services/MockFilterRepoService.ts +3 -0
- package/src/utils/process.test.ts +15 -0
- package/src/utils/process.ts +21 -1
package/cli.ts
CHANGED
|
@@ -186,6 +186,7 @@ const commands: Record<string, Command> = {
|
|
|
186
186
|
emit: options.emit || options.branch,
|
|
187
187
|
silent: options.silent,
|
|
188
188
|
force: options.force,
|
|
189
|
+
noVerify: options["no-verify"],
|
|
189
190
|
verbose: options.verbose,
|
|
190
191
|
pr: options.pr,
|
|
191
192
|
}),
|
|
@@ -593,6 +594,9 @@ try {
|
|
|
593
594
|
type: "boolean",
|
|
594
595
|
short: "f",
|
|
595
596
|
},
|
|
597
|
+
"no-verify": {
|
|
598
|
+
type: "boolean",
|
|
599
|
+
},
|
|
596
600
|
verbose: {
|
|
597
601
|
type: "boolean",
|
|
598
602
|
short: "v",
|
package/package.json
CHANGED
|
@@ -349,6 +349,37 @@ describe("emit command", () => {
|
|
|
349
349
|
const lastCall = getLastCapturedFilterRepoCall()
|
|
350
350
|
expect(lastCall).toBeDefined()
|
|
351
351
|
expect(lastCall!.streamOutput).toBe(true)
|
|
352
|
+
expect(lastCall!.progressIntervalMs).toBe(5000)
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
test("logs filter preflight diagnostics in verbose mode", async () => {
|
|
356
|
+
await checkoutBranch(tempDir, "main")
|
|
357
|
+
await createBranch(tempDir, "agency--filter-diagnostics-test")
|
|
358
|
+
|
|
359
|
+
await Bun.write(
|
|
360
|
+
join(tempDir, "agency.json"),
|
|
361
|
+
JSON.stringify({
|
|
362
|
+
version: 1,
|
|
363
|
+
injectedFiles: ["AGENTS.md"],
|
|
364
|
+
template: "test",
|
|
365
|
+
createdAt: new Date().toISOString(),
|
|
366
|
+
}),
|
|
367
|
+
)
|
|
368
|
+
await addAndCommit(tempDir, "agency.json", "Add agency.json")
|
|
369
|
+
|
|
370
|
+
const logs: string[] = []
|
|
371
|
+
const originalLog = console.log
|
|
372
|
+
console.log = (...args: any[]) => logs.push(args.join(" "))
|
|
373
|
+
|
|
374
|
+
try {
|
|
375
|
+
await runTestEffectWithMockFilterRepo(emit({ verbose: true }))
|
|
376
|
+
} finally {
|
|
377
|
+
console.log = originalLog
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
expect(logs.join("\n")).toContain("Commit range contains")
|
|
381
|
+
expect(logs.join("\n")).toContain("Filtered paths are touched by")
|
|
382
|
+
expect(logs.join("\n")).toContain("Filtered path diff contains")
|
|
352
383
|
})
|
|
353
384
|
|
|
354
385
|
test("includes symlink targets in files to filter", async () => {
|
package/src/commands/emit.ts
CHANGED
|
@@ -143,7 +143,7 @@ export const emitCore = (gitRoot: string, options: EmitOptions) =>
|
|
|
143
143
|
gitRoot,
|
|
144
144
|
currentBranch,
|
|
145
145
|
baseBranch,
|
|
146
|
-
|
|
146
|
+
options,
|
|
147
147
|
)
|
|
148
148
|
|
|
149
149
|
verboseLog(`Branch forked at commit: ${highlight.commit(forkPoint)}`)
|
|
@@ -223,11 +223,19 @@ export const emitCore = (gitRoot: string, options: EmitOptions) =>
|
|
|
223
223
|
verboseLog(
|
|
224
224
|
`git-filter-repo will evaluate ${filesToFilter.length} filtered path${filesToFilter.length === 1 ? "" : "s"}`,
|
|
225
225
|
)
|
|
226
|
+
yield* logFilterPreflight(
|
|
227
|
+
gitRoot,
|
|
228
|
+
forkPoint,
|
|
229
|
+
emitBranchName,
|
|
230
|
+
filesToFilter,
|
|
231
|
+
verboseLog,
|
|
232
|
+
)
|
|
226
233
|
|
|
227
234
|
yield* filterRepo.run(gitRoot, filterRepoArgs, {
|
|
228
235
|
env: { GIT_CONFIG_GLOBAL: "" },
|
|
229
236
|
verboseLog,
|
|
230
237
|
streamOutput: verbose,
|
|
238
|
+
progressIntervalMs: 5_000,
|
|
231
239
|
})
|
|
232
240
|
|
|
233
241
|
verboseLog("git-filter-repo completed")
|
|
@@ -245,6 +253,77 @@ export const emitCore = (gitRoot: string, options: EmitOptions) =>
|
|
|
245
253
|
log(done(`Emitted ${highlight.branch(emitBranchName)}`))
|
|
246
254
|
})
|
|
247
255
|
|
|
256
|
+
const logFilterPreflight = (
|
|
257
|
+
gitRoot: string,
|
|
258
|
+
forkPoint: string,
|
|
259
|
+
emitBranchName: string,
|
|
260
|
+
filesToFilter: readonly string[],
|
|
261
|
+
verboseLog: (message: string) => void,
|
|
262
|
+
) =>
|
|
263
|
+
Effect.gen(function* () {
|
|
264
|
+
const git = yield* GitService
|
|
265
|
+
const range = `${forkPoint}..${emitBranchName}`
|
|
266
|
+
const summarize = (value: string) => value.trim() || "0"
|
|
267
|
+
|
|
268
|
+
const commitCount = yield* git
|
|
269
|
+
.runGitCommand(["git", "rev-list", "--count", range], gitRoot, {
|
|
270
|
+
captureOutput: true,
|
|
271
|
+
})
|
|
272
|
+
.pipe(Effect.option)
|
|
273
|
+
|
|
274
|
+
if (commitCount._tag === "Some" && commitCount.value.exitCode === 0) {
|
|
275
|
+
verboseLog(
|
|
276
|
+
`Commit range contains ${summarize(commitCount.value.stdout)} commit${summarize(commitCount.value.stdout) === "1" ? "" : "s"}`,
|
|
277
|
+
)
|
|
278
|
+
} else {
|
|
279
|
+
verboseLog("Unable to count commits in git-filter-repo range")
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (filesToFilter.length === 0) {
|
|
283
|
+
verboseLog("No filtered paths were resolved before git-filter-repo")
|
|
284
|
+
return
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const pathArgs = ["--", ...filesToFilter]
|
|
288
|
+
const matchingCommitCount = yield* git
|
|
289
|
+
.runGitCommand(
|
|
290
|
+
["git", "rev-list", "--count", range, ...pathArgs],
|
|
291
|
+
gitRoot,
|
|
292
|
+
{ captureOutput: true },
|
|
293
|
+
)
|
|
294
|
+
.pipe(Effect.option)
|
|
295
|
+
|
|
296
|
+
if (
|
|
297
|
+
matchingCommitCount._tag === "Some" &&
|
|
298
|
+
matchingCommitCount.value.exitCode === 0
|
|
299
|
+
) {
|
|
300
|
+
verboseLog(
|
|
301
|
+
`Filtered paths are touched by ${summarize(matchingCommitCount.value.stdout)} commit${summarize(matchingCommitCount.value.stdout) === "1" ? "" : "s"} in the range`,
|
|
302
|
+
)
|
|
303
|
+
} else {
|
|
304
|
+
verboseLog("Unable to count commits touching filtered paths")
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const changedFiles = yield* git
|
|
308
|
+
.runGitCommand(
|
|
309
|
+
["git", "diff", "--name-only", range, ...pathArgs],
|
|
310
|
+
gitRoot,
|
|
311
|
+
{ captureOutput: true },
|
|
312
|
+
)
|
|
313
|
+
.pipe(Effect.option)
|
|
314
|
+
|
|
315
|
+
if (changedFiles._tag === "Some" && changedFiles.value.exitCode === 0) {
|
|
316
|
+
const changedFileCount = changedFiles.value.stdout
|
|
317
|
+
.split("\n")
|
|
318
|
+
.filter(Boolean).length
|
|
319
|
+
verboseLog(
|
|
320
|
+
`Filtered path diff contains ${changedFileCount} changed file${changedFileCount === 1 ? "" : "s"}`,
|
|
321
|
+
)
|
|
322
|
+
} else {
|
|
323
|
+
verboseLog("Unable to count changed filtered files")
|
|
324
|
+
}
|
|
325
|
+
})
|
|
326
|
+
|
|
248
327
|
// Helper: Ensure emitBranch is set in agency.json metadata
|
|
249
328
|
const ensureEmitBranchInMetadata = (
|
|
250
329
|
gitRoot: string,
|
|
@@ -368,11 +447,11 @@ const findBestForkPoint = (
|
|
|
368
447
|
gitRoot: string,
|
|
369
448
|
featureBranch: string,
|
|
370
449
|
baseBranch: string,
|
|
371
|
-
|
|
450
|
+
options: Pick<EmitOptions, "silent" | "verbose">,
|
|
372
451
|
) =>
|
|
373
452
|
Effect.gen(function* () {
|
|
374
453
|
const git = yield* GitService
|
|
375
|
-
const { verboseLog } = createLoggers(
|
|
454
|
+
const { verboseLog } = createLoggers(options)
|
|
376
455
|
|
|
377
456
|
// Strategy 1: Get fork-point against local base branch
|
|
378
457
|
const localForkPoint = yield* getForkPointOrMergeBase(
|
package/src/commands/push.ts
CHANGED
|
@@ -19,6 +19,7 @@ interface PushOptions extends BaseCommandOptions {
|
|
|
19
19
|
emit?: string
|
|
20
20
|
branch?: string // Deprecated: use emit instead
|
|
21
21
|
force?: boolean
|
|
22
|
+
noVerify?: boolean
|
|
22
23
|
pr?: boolean
|
|
23
24
|
skipFilter?: boolean
|
|
24
25
|
}
|
|
@@ -186,6 +187,7 @@ const pushCore = (gitRoot: string, options: PushOptions) =>
|
|
|
186
187
|
withSpinner(
|
|
187
188
|
pushBranchToRemoteEffect(gitRoot, emitBranchName, remote, {
|
|
188
189
|
force: options.force,
|
|
190
|
+
noVerify: options.noVerify,
|
|
189
191
|
verbose: options.verbose,
|
|
190
192
|
}),
|
|
191
193
|
{
|
|
@@ -243,16 +245,17 @@ const pushBranchToRemoteEffect = (
|
|
|
243
245
|
remote: string,
|
|
244
246
|
options: {
|
|
245
247
|
readonly force?: boolean
|
|
248
|
+
readonly noVerify?: boolean
|
|
246
249
|
readonly verbose?: boolean
|
|
247
250
|
},
|
|
248
251
|
) =>
|
|
249
252
|
Effect.gen(function* () {
|
|
250
253
|
const git = yield* GitService
|
|
251
|
-
const { force = false } = options
|
|
254
|
+
const { force = false, noVerify = false } = options
|
|
252
255
|
|
|
253
256
|
// Try pushing without force first
|
|
254
257
|
const pushResult = yield* git
|
|
255
|
-
.push(gitRoot, remote, branchName, { setUpstream: true })
|
|
258
|
+
.push(gitRoot, remote, branchName, { setUpstream: true, noVerify })
|
|
256
259
|
.pipe(
|
|
257
260
|
Effect.catchAll((error: any) =>
|
|
258
261
|
Effect.succeed({
|
|
@@ -275,7 +278,11 @@ const pushBranchToRemoteEffect = (
|
|
|
275
278
|
if (needsForce && force) {
|
|
276
279
|
// User provided --force flag, retry with force
|
|
277
280
|
const forceResult = yield* git
|
|
278
|
-
.push(gitRoot, remote, branchName, {
|
|
281
|
+
.push(gitRoot, remote, branchName, {
|
|
282
|
+
setUpstream: true,
|
|
283
|
+
force: true,
|
|
284
|
+
noVerify,
|
|
285
|
+
})
|
|
279
286
|
.pipe(
|
|
280
287
|
Effect.catchAll((error: any) =>
|
|
281
288
|
Effect.succeed({
|
|
@@ -405,15 +412,17 @@ Arguments:
|
|
|
405
412
|
|
|
406
413
|
Options:
|
|
407
414
|
--emit Custom name for emit branch (defaults to pattern from config)
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
415
|
+
--branch (Deprecated: use --emit) Custom name for emit branch
|
|
416
|
+
-f, --force Force push to remote if branch has diverged
|
|
417
|
+
--no-verify Bypass git pre-push hooks
|
|
418
|
+
--pr Open GitHub PR in browser after pushing (requires gh CLI)
|
|
411
419
|
|
|
412
420
|
Examples:
|
|
413
421
|
agency push # Create PR, push, return to source
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
422
|
+
agency push origin/main # Explicitly use origin/main as base
|
|
423
|
+
agency push --force # Force push if branch has diverged
|
|
424
|
+
agency push --no-verify # Push without running pre-push hooks
|
|
425
|
+
agency push --pr # Push and open GitHub PR in browser
|
|
417
426
|
|
|
418
427
|
Notes:
|
|
419
428
|
- Must be run from a source branch (not a emit branch)
|
|
@@ -46,6 +46,7 @@ export class FilterRepoService extends Effect.Service<FilterRepoService>()(
|
|
|
46
46
|
readonly env?: Record<string, string>
|
|
47
47
|
readonly verboseLog?: (message: string) => void
|
|
48
48
|
readonly streamOutput?: boolean
|
|
49
|
+
readonly progressIntervalMs?: number
|
|
49
50
|
},
|
|
50
51
|
) =>
|
|
51
52
|
Effect.gen(function* () {
|
|
@@ -79,6 +80,15 @@ export class FilterRepoService extends Effect.Service<FilterRepoService>()(
|
|
|
79
80
|
env: options?.env,
|
|
80
81
|
stdout: options?.streamOutput ? "inherit" : "pipe",
|
|
81
82
|
stderr: options?.streamOutput ? "inherit" : "pipe",
|
|
83
|
+
onProgress: options?.streamOutput
|
|
84
|
+
? ({ elapsedMs, pid }) => {
|
|
85
|
+
const elapsedSeconds = Math.round(elapsedMs / 1000)
|
|
86
|
+
verboseLog(
|
|
87
|
+
`git-filter-repo still running after ${elapsedSeconds}s${pid ? ` (pid ${pid})` : ""}`,
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
: undefined,
|
|
91
|
+
progressIntervalMs: options?.progressIntervalMs,
|
|
82
92
|
}),
|
|
83
93
|
Effect.mapError(
|
|
84
94
|
(error) =>
|
|
@@ -1046,6 +1046,7 @@ export class GitService extends Effect.Service<GitService>()("GitService", {
|
|
|
1046
1046
|
options?: {
|
|
1047
1047
|
readonly setUpstream?: boolean
|
|
1048
1048
|
readonly force?: boolean
|
|
1049
|
+
readonly noVerify?: boolean
|
|
1049
1050
|
},
|
|
1050
1051
|
) => {
|
|
1051
1052
|
const args = ["git", "push"]
|
|
@@ -1055,6 +1056,9 @@ export class GitService extends Effect.Service<GitService>()("GitService", {
|
|
|
1055
1056
|
if (options?.force) {
|
|
1056
1057
|
args.push("--force")
|
|
1057
1058
|
}
|
|
1059
|
+
if (options?.noVerify) {
|
|
1060
|
+
args.push("--no-verify")
|
|
1061
|
+
}
|
|
1058
1062
|
args.push(remote, branch)
|
|
1059
1063
|
|
|
1060
1064
|
return pipe(
|
|
@@ -9,6 +9,7 @@ export interface CapturedFilterRepoCall {
|
|
|
9
9
|
args: readonly string[]
|
|
10
10
|
env?: Record<string, string>
|
|
11
11
|
streamOutput?: boolean
|
|
12
|
+
progressIntervalMs?: number
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
/**
|
|
@@ -66,6 +67,7 @@ export class MockFilterRepoService extends Effect.Service<MockFilterRepoService>
|
|
|
66
67
|
readonly env?: Record<string, string>
|
|
67
68
|
readonly verboseLog?: (message: string) => void
|
|
68
69
|
readonly streamOutput?: boolean
|
|
70
|
+
readonly progressIntervalMs?: number
|
|
69
71
|
},
|
|
70
72
|
) => {
|
|
71
73
|
// Capture the call
|
|
@@ -74,6 +76,7 @@ export class MockFilterRepoService extends Effect.Service<MockFilterRepoService>
|
|
|
74
76
|
args,
|
|
75
77
|
env: options?.env,
|
|
76
78
|
streamOutput: options?.streamOutput,
|
|
79
|
+
progressIntervalMs: options?.progressIntervalMs,
|
|
77
80
|
})
|
|
78
81
|
|
|
79
82
|
// Return a successful result
|
|
@@ -23,4 +23,19 @@ describe("spawnProcess", () => {
|
|
|
23
23
|
expect(result.stderr).toContain("err:0:")
|
|
24
24
|
expect(result.stderr).toContain("err:1999:")
|
|
25
25
|
})
|
|
26
|
+
|
|
27
|
+
test("reports progress while process is still running", async () => {
|
|
28
|
+
const progress: number[] = []
|
|
29
|
+
const script = "await new Promise((resolve) => setTimeout(resolve, 80))"
|
|
30
|
+
|
|
31
|
+
const result = await Effect.runPromise(
|
|
32
|
+
spawnProcess([process.execPath, "-e", script], {
|
|
33
|
+
onProgress: ({ elapsedMs }) => progress.push(elapsedMs),
|
|
34
|
+
progressIntervalMs: 10,
|
|
35
|
+
}),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
expect(result.exitCode).toBe(0)
|
|
39
|
+
expect(progress.length).toBeGreaterThan(0)
|
|
40
|
+
})
|
|
26
41
|
})
|
package/src/utils/process.ts
CHANGED
|
@@ -18,6 +18,11 @@ interface SpawnOptions {
|
|
|
18
18
|
readonly stdout?: "pipe" | "inherit"
|
|
19
19
|
readonly stderr?: "pipe" | "inherit"
|
|
20
20
|
readonly env?: Record<string, string>
|
|
21
|
+
readonly onProgress?: (progress: {
|
|
22
|
+
readonly elapsedMs: number
|
|
23
|
+
readonly pid?: number
|
|
24
|
+
}) => void
|
|
25
|
+
readonly progressIntervalMs?: number
|
|
21
26
|
}
|
|
22
27
|
|
|
23
28
|
/**
|
|
@@ -54,6 +59,17 @@ export const spawnProcess = (
|
|
|
54
59
|
stderr: options?.stderr ?? "pipe",
|
|
55
60
|
env: options?.env ? { ...process.env, ...options.env } : process.env,
|
|
56
61
|
})
|
|
62
|
+
const startedAt = Date.now()
|
|
63
|
+
const progressInterval = options?.onProgress
|
|
64
|
+
? setInterval(
|
|
65
|
+
() =>
|
|
66
|
+
options.onProgress?.({
|
|
67
|
+
elapsedMs: Date.now() - startedAt,
|
|
68
|
+
pid: proc.pid,
|
|
69
|
+
}),
|
|
70
|
+
options.progressIntervalMs ?? 10_000,
|
|
71
|
+
)
|
|
72
|
+
: null
|
|
57
73
|
|
|
58
74
|
// Start draining stdout/stderr immediately so verbose subprocesses
|
|
59
75
|
// cannot block on filled pipe buffers before they exit.
|
|
@@ -70,7 +86,11 @@ export const spawnProcess = (
|
|
|
70
86
|
proc.exited,
|
|
71
87
|
stdoutPromise,
|
|
72
88
|
stderrPromise,
|
|
73
|
-
])
|
|
89
|
+
]).finally(() => {
|
|
90
|
+
if (progressInterval) {
|
|
91
|
+
clearInterval(progressInterval)
|
|
92
|
+
}
|
|
93
|
+
})
|
|
74
94
|
|
|
75
95
|
return {
|
|
76
96
|
stdout: stdout.trim(),
|