@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "1.8.7",
3
+ "version": "1.9.1",
4
4
  "description": "Manages personal agents files",
5
5
  "keywords": [
6
6
  "agents",
@@ -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 () => {
@@ -143,7 +143,7 @@ export const emitCore = (gitRoot: string, options: EmitOptions) =>
143
143
  gitRoot,
144
144
  currentBranch,
145
145
  baseBranch,
146
- verbose,
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
- verbose: boolean,
450
+ options: Pick<EmitOptions, "silent" | "verbose">,
372
451
  ) =>
373
452
  Effect.gen(function* () {
374
453
  const git = yield* GitService
375
- const { verboseLog } = createLoggers({ verbose })
454
+ const { verboseLog } = createLoggers(options)
376
455
 
377
456
  // Strategy 1: Get fork-point against local base branch
378
457
  const localForkPoint = yield* getForkPointOrMergeBase(
@@ -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, { setUpstream: true, force: true })
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
- --branch (Deprecated: use --emit) Custom name for emit branch
409
- -f, --force Force push to remote if branch has diverged
410
- --pr Open GitHub PR in browser after pushing (requires gh CLI)
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
- agency push origin/main # Explicitly use origin/main as base
415
- agency push --force # Force push if branch has diverged
416
- agency push --pr # Push and open GitHub PR in browser
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
  })
@@ -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(),