@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 +1 -1
- package/src/commands/emit.test.ts +31 -0
- package/src/commands/emit.ts +82 -3
- package/src/services/FilterRepoService.ts +10 -0
- package/src/services/MockFilterRepoService.ts +3 -0
- package/src/utils/process.test.ts +15 -0
- package/src/utils/process.ts +21 -1
- package/src/utils/spinner.test.ts +45 -0
- package/src/utils/spinner.ts +39 -26
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(
|
|
@@ -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
|
})
|
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(),
|
|
@@ -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
|
+
})
|
package/src/utils/spinner.ts
CHANGED
|
@@ -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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
}
|