@markjaquith/agency 1.9.0 → 1.9.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "1.9.0",
3
+ "version": "1.9.2",
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(
@@ -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) =>
@@ -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(),
@@ -0,0 +1,45 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { Effect } from "effect"
3
+ import { withSpinner } from "./spinner"
4
+
5
+ describe("withSpinner", () => {
6
+ test("clears and stops the spinner when an Effect failure has no fail text", async () => {
7
+ const originalNodeEnv = process.env.NODE_ENV
8
+ const originalBunEnv = process.env.BUN_ENV
9
+ delete process.env.NODE_ENV
10
+ delete process.env.BUN_ENV
11
+
12
+ const calls: string[] = []
13
+ const error = new Error("boom")
14
+
15
+ try {
16
+ await expect(
17
+ Effect.runPromise(
18
+ withSpinner(Effect.fail(error), {
19
+ text: "Working",
20
+ createSpinner: () => ({
21
+ succeed: () => calls.push("succeed"),
22
+ fail: () => calls.push("fail"),
23
+ clear: () => calls.push("clear"),
24
+ stop: () => calls.push("stop"),
25
+ }),
26
+ }),
27
+ ),
28
+ ).rejects.toThrow("boom")
29
+
30
+ expect(calls).toEqual(["clear", "stop"])
31
+ } finally {
32
+ if (originalNodeEnv === undefined) {
33
+ delete process.env.NODE_ENV
34
+ } else {
35
+ process.env.NODE_ENV = originalNodeEnv
36
+ }
37
+
38
+ if (originalBunEnv === undefined) {
39
+ delete process.env.BUN_ENV
40
+ } else {
41
+ process.env.BUN_ENV = originalBunEnv
42
+ }
43
+ }
44
+ })
45
+ })
@@ -1,5 +1,12 @@
1
1
  import ora from "ora"
2
- import { Effect } from "effect"
2
+ import { Effect, Exit } from "effect"
3
+
4
+ interface Spinner {
5
+ succeed: (text?: string) => unknown
6
+ fail: (text?: string) => unknown
7
+ clear: () => unknown
8
+ stop: () => unknown
9
+ }
3
10
 
4
11
  /**
5
12
  * Check if we're running in a test environment
@@ -20,6 +27,8 @@ interface SpinnerConfig {
20
27
  failText?: string
21
28
  /** Whether the spinner is enabled (defaults to true) */
22
29
  enabled?: boolean
30
+ /** Creates the spinner instance. Intended for tests. */
31
+ createSpinner?: (text: string) => Spinner
23
32
  }
24
33
 
25
34
  /**
@@ -53,31 +62,35 @@ export const withSpinner = <A, E, R>(
53
62
  return effect
54
63
  }
55
64
 
56
- return Effect.gen(function* () {
57
- const spinner = ora({
58
- text,
59
- spinner: "dots",
60
- color: "cyan",
61
- }).start()
62
-
63
- try {
64
- const result = yield* effect
65
+ const createSpinner =
66
+ config.createSpinner ??
67
+ ((text: string) =>
68
+ ora({
69
+ text,
70
+ spinner: "dots",
71
+ color: "cyan",
72
+ }).start())
65
73
 
66
- if (successText) {
67
- spinner.succeed(successText)
68
- } else {
69
- spinner.stop()
70
- }
74
+ return Effect.acquireUseRelease(
75
+ Effect.sync(() => createSpinner(text)),
76
+ () => effect,
77
+ (spinner, exit) =>
78
+ Effect.sync(() => {
79
+ if (Exit.isSuccess(exit)) {
80
+ if (successText) {
81
+ spinner.succeed(successText)
82
+ } else {
83
+ spinner.stop()
84
+ }
85
+ return
86
+ }
71
87
 
72
- return result
73
- } catch (error) {
74
- if (failText) {
75
- spinner.fail(failText)
76
- } else {
77
- spinner.clear()
78
- spinner.stop()
79
- }
80
- throw error
81
- }
82
- })
88
+ if (failText) {
89
+ spinner.fail(failText)
90
+ } else {
91
+ spinner.clear()
92
+ spinner.stop()
93
+ }
94
+ }),
95
+ )
83
96
  }