@markjaquith/agency 1.8.5 → 1.8.7
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 +45 -0
- package/src/commands/emit.ts +31 -2
- package/src/commands/push.test.ts +44 -0
- package/src/commands/push.ts +14 -8
- package/src/services/FilterRepoService.ts +12 -0
- package/src/services/MockFilterRepoService.ts +4 -0
- package/src/utils/git-path.ts +6 -0
- package/src/utils/process.test.ts +26 -0
- package/src/utils/process.ts +16 -9
package/package.json
CHANGED
|
@@ -3,6 +3,7 @@ import { join } from "path"
|
|
|
3
3
|
import { symlink } from "fs/promises"
|
|
4
4
|
import { emit } from "../commands/emit"
|
|
5
5
|
import { task } from "../commands/task"
|
|
6
|
+
import { resolveGitInternalPath } from "../utils/git-path"
|
|
6
7
|
import {
|
|
7
8
|
createTempDir,
|
|
8
9
|
cleanupTempDir,
|
|
@@ -326,6 +327,30 @@ describe("emit command", () => {
|
|
|
326
327
|
expect(lastCall!.env?.GIT_CONFIG_GLOBAL).toBe("")
|
|
327
328
|
})
|
|
328
329
|
|
|
330
|
+
test("streams git-filter-repo output in verbose mode", async () => {
|
|
331
|
+
await checkoutBranch(tempDir, "main")
|
|
332
|
+
await createBranch(tempDir, "agency--verbose-filter-test")
|
|
333
|
+
|
|
334
|
+
await Bun.write(
|
|
335
|
+
join(tempDir, "agency.json"),
|
|
336
|
+
JSON.stringify({
|
|
337
|
+
version: 1,
|
|
338
|
+
injectedFiles: ["AGENTS.md"],
|
|
339
|
+
template: "test",
|
|
340
|
+
createdAt: new Date().toISOString(),
|
|
341
|
+
}),
|
|
342
|
+
)
|
|
343
|
+
await addAndCommit(tempDir, "agency.json", "Add agency.json")
|
|
344
|
+
|
|
345
|
+
await runTestEffectWithMockFilterRepo(
|
|
346
|
+
emit({ silent: true, verbose: true }),
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
const lastCall = getLastCapturedFilterRepoCall()
|
|
350
|
+
expect(lastCall).toBeDefined()
|
|
351
|
+
expect(lastCall!.streamOutput).toBe(true)
|
|
352
|
+
})
|
|
353
|
+
|
|
329
354
|
test("includes symlink targets in files to filter", async () => {
|
|
330
355
|
// Set up fresh branch
|
|
331
356
|
await checkoutBranch(tempDir, "main")
|
|
@@ -400,4 +425,24 @@ describe("emit command", () => {
|
|
|
400
425
|
expect(lastCall!.args).toContain(".agents/foo/bar.whatever")
|
|
401
426
|
})
|
|
402
427
|
})
|
|
428
|
+
|
|
429
|
+
describe("git path resolution", () => {
|
|
430
|
+
test("resolves relative git internal paths from git root", () => {
|
|
431
|
+
expect(
|
|
432
|
+
resolveGitInternalPath(
|
|
433
|
+
"/repo/worktree",
|
|
434
|
+
"../.git/worktrees/worktree/filter-repo",
|
|
435
|
+
),
|
|
436
|
+
).toBe("/repo/.git/worktrees/worktree/filter-repo")
|
|
437
|
+
})
|
|
438
|
+
|
|
439
|
+
test("preserves absolute git internal paths", () => {
|
|
440
|
+
expect(
|
|
441
|
+
resolveGitInternalPath(
|
|
442
|
+
"/repo/worktree",
|
|
443
|
+
"/repo/.git/worktrees/worktree/filter-repo",
|
|
444
|
+
),
|
|
445
|
+
).toBe("/repo/.git/worktrees/worktree/filter-repo")
|
|
446
|
+
})
|
|
447
|
+
})
|
|
403
448
|
})
|
package/src/commands/emit.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
resolveBaseBranch,
|
|
23
23
|
withBranchProtection,
|
|
24
24
|
} from "../utils/effect"
|
|
25
|
+
import { resolveGitInternalPath } from "../utils/git-path"
|
|
25
26
|
import { withSpinner } from "../utils/spinner"
|
|
26
27
|
import { AGENCY_REMOVE_COMMIT } from "../constants"
|
|
27
28
|
|
|
@@ -186,8 +187,12 @@ export const emitCore = (gitRoot: string, options: EmitOptions) =>
|
|
|
186
187
|
|
|
187
188
|
// Filter backpack files
|
|
188
189
|
const filterOperation = Effect.gen(function* () {
|
|
189
|
-
//
|
|
190
|
-
|
|
190
|
+
// Resolve the actual git-filter-repo state directory.
|
|
191
|
+
// In linked worktrees this lives under the worktree git dir, not repo/.git.
|
|
192
|
+
const filterRepoDir = yield* getFilterRepoStateDir(gitRoot)
|
|
193
|
+
verboseLog(
|
|
194
|
+
`Cleaning git-filter-repo state in ${highlight.file(filterRepoDir)}`,
|
|
195
|
+
)
|
|
191
196
|
yield* fs.deleteDirectory(filterRepoDir)
|
|
192
197
|
verboseLog("Cleaned up previous git-filter-repo state")
|
|
193
198
|
|
|
@@ -214,8 +219,15 @@ export const emitCore = (gitRoot: string, options: EmitOptions) =>
|
|
|
214
219
|
`${forkPoint}..${emitBranchName}`,
|
|
215
220
|
]
|
|
216
221
|
|
|
222
|
+
verboseLog(`git-filter-repo refs: ${forkPoint}..${emitBranchName}`)
|
|
223
|
+
verboseLog(
|
|
224
|
+
`git-filter-repo will evaluate ${filesToFilter.length} filtered path${filesToFilter.length === 1 ? "" : "s"}`,
|
|
225
|
+
)
|
|
226
|
+
|
|
217
227
|
yield* filterRepo.run(gitRoot, filterRepoArgs, {
|
|
218
228
|
env: { GIT_CONFIG_GLOBAL: "" },
|
|
229
|
+
verboseLog,
|
|
230
|
+
streamOutput: verbose,
|
|
219
231
|
})
|
|
220
232
|
|
|
221
233
|
verboseLog("git-filter-repo completed")
|
|
@@ -327,6 +339,23 @@ const getRemoteTrackingBranch = (
|
|
|
327
339
|
return null
|
|
328
340
|
})
|
|
329
341
|
|
|
342
|
+
const getFilterRepoStateDir = (gitRoot: string) =>
|
|
343
|
+
Effect.gen(function* () {
|
|
344
|
+
const git = yield* GitService
|
|
345
|
+
const result = yield* git.runGitCommand(
|
|
346
|
+
["git", "rev-parse", "--git-path", "filter-repo"],
|
|
347
|
+
gitRoot,
|
|
348
|
+
{ captureOutput: true },
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
const gitPath = result.stdout.trim()
|
|
352
|
+
if (result.exitCode !== 0 || !gitPath) {
|
|
353
|
+
return `${gitRoot}/.git/filter-repo`
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return resolveGitInternalPath(gitRoot, gitPath)
|
|
357
|
+
})
|
|
358
|
+
|
|
330
359
|
/**
|
|
331
360
|
* Find the best fork point by checking both local and remote tracking branches.
|
|
332
361
|
*
|
|
@@ -314,6 +314,50 @@ describe("push command", () => {
|
|
|
314
314
|
expect(await getCurrentBranch(tempDir)).toBe("agency--feature")
|
|
315
315
|
})
|
|
316
316
|
|
|
317
|
+
test("reports pre-push hook output without suggesting --force", async () => {
|
|
318
|
+
await Bun.spawn(
|
|
319
|
+
["git", "config", "core.hooksPath", join(tempDir, ".git", "hooks")],
|
|
320
|
+
{
|
|
321
|
+
cwd: tempDir,
|
|
322
|
+
stdout: "pipe",
|
|
323
|
+
stderr: "pipe",
|
|
324
|
+
},
|
|
325
|
+
).exited
|
|
326
|
+
const hookPath = join(tempDir, ".git", "hooks", "pre-push")
|
|
327
|
+
await Bun.write(
|
|
328
|
+
hookPath,
|
|
329
|
+
[
|
|
330
|
+
"#!/bin/sh",
|
|
331
|
+
"printf 'pre-push hook stdout failure\\n'",
|
|
332
|
+
"printf 'pre-push hook stderr failure\\n' >&2",
|
|
333
|
+
"exit 1",
|
|
334
|
+
"",
|
|
335
|
+
].join("\n"),
|
|
336
|
+
)
|
|
337
|
+
await Bun.spawn(["chmod", "+x", hookPath], {
|
|
338
|
+
cwd: tempDir,
|
|
339
|
+
stdout: "pipe",
|
|
340
|
+
stderr: "pipe",
|
|
341
|
+
}).exited
|
|
342
|
+
|
|
343
|
+
let error: unknown
|
|
344
|
+
try {
|
|
345
|
+
await runTestEffect(
|
|
346
|
+
push({ baseBranch: "main", silent: true, skipFilter: true }),
|
|
347
|
+
)
|
|
348
|
+
} catch (caught) {
|
|
349
|
+
error = caught
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
expect(error).toBeInstanceOf(Error)
|
|
353
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
354
|
+
expect(message).toContain("pre-push hook stdout failure")
|
|
355
|
+
expect(message).toContain("pre-push hook stderr failure")
|
|
356
|
+
expect(message).not.toContain("agency push --force")
|
|
357
|
+
|
|
358
|
+
expect(await getCurrentBranch(tempDir)).toBe("agency--feature")
|
|
359
|
+
})
|
|
360
|
+
|
|
317
361
|
test("does not report force push when --force is provided but not needed", async () => {
|
|
318
362
|
// Capture output to check for force push message
|
|
319
363
|
const originalLog = console.log
|
package/src/commands/push.ts
CHANGED
|
@@ -44,15 +44,21 @@ const getPushFailureStderr = (error: unknown): string => {
|
|
|
44
44
|
return String(error).trim()
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
const formatProcessOutput = (result: {
|
|
48
|
+
readonly stdout?: string
|
|
49
|
+
readonly stderr?: string
|
|
50
|
+
}): string => [result.stdout, result.stderr].filter(Boolean).join("\n").trim()
|
|
51
|
+
|
|
47
52
|
const pushFailureNeedsForce = (stderr: string): boolean => {
|
|
48
53
|
const normalized = stderr.toLowerCase()
|
|
49
54
|
|
|
50
|
-
return
|
|
51
|
-
"non-fast-forward"
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
55
|
+
return (
|
|
56
|
+
normalized.includes("non-fast-forward") ||
|
|
57
|
+
normalized.includes(
|
|
58
|
+
"updates were rejected because the tip of your current branch is behind",
|
|
59
|
+
) ||
|
|
60
|
+
(normalized.includes("[rejected]") && normalized.includes("fetch first"))
|
|
61
|
+
)
|
|
56
62
|
}
|
|
57
63
|
|
|
58
64
|
const formatPushFailure = (error: unknown): Error => {
|
|
@@ -261,7 +267,7 @@ const pushBranchToRemoteEffect = (
|
|
|
261
267
|
|
|
262
268
|
// If push failed, check if we should retry with --force
|
|
263
269
|
if (pushResult.exitCode !== 0) {
|
|
264
|
-
const stderr = pushResult
|
|
270
|
+
const stderr = formatProcessOutput(pushResult)
|
|
265
271
|
|
|
266
272
|
// Check if this is a force-push-needed error
|
|
267
273
|
const needsForce = pushFailureNeedsForce(stderr)
|
|
@@ -283,7 +289,7 @@ const pushBranchToRemoteEffect = (
|
|
|
283
289
|
if (forceResult.exitCode !== 0) {
|
|
284
290
|
return yield* Effect.fail(
|
|
285
291
|
new Error(
|
|
286
|
-
`Failed to force push branch to remote: ${forceResult
|
|
292
|
+
`Failed to force push branch to remote: ${formatProcessOutput(forceResult)}`,
|
|
287
293
|
),
|
|
288
294
|
)
|
|
289
295
|
}
|
|
@@ -44,6 +44,8 @@ export class FilterRepoService extends Effect.Service<FilterRepoService>()(
|
|
|
44
44
|
args: readonly string[],
|
|
45
45
|
options?: {
|
|
46
46
|
readonly env?: Record<string, string>
|
|
47
|
+
readonly verboseLog?: (message: string) => void
|
|
48
|
+
readonly streamOutput?: boolean
|
|
47
49
|
},
|
|
48
50
|
) =>
|
|
49
51
|
Effect.gen(function* () {
|
|
@@ -64,11 +66,19 @@ export class FilterRepoService extends Effect.Service<FilterRepoService>()(
|
|
|
64
66
|
}
|
|
65
67
|
|
|
66
68
|
const fullArgs = ["git-filter-repo", ...args]
|
|
69
|
+
const verboseLog = options?.verboseLog ?? (() => {})
|
|
70
|
+
|
|
71
|
+
verboseLog(`Running ${fullArgs.join(" ")} in ${gitRoot}`)
|
|
72
|
+
if (options?.streamOutput) {
|
|
73
|
+
verboseLog("Streaming git-filter-repo output directly")
|
|
74
|
+
}
|
|
67
75
|
|
|
68
76
|
const result = yield* pipe(
|
|
69
77
|
spawnProcess(fullArgs, {
|
|
70
78
|
cwd: gitRoot,
|
|
71
79
|
env: options?.env,
|
|
80
|
+
stdout: options?.streamOutput ? "inherit" : "pipe",
|
|
81
|
+
stderr: options?.streamOutput ? "inherit" : "pipe",
|
|
72
82
|
}),
|
|
73
83
|
Effect.mapError(
|
|
74
84
|
(error) =>
|
|
@@ -79,6 +89,8 @@ export class FilterRepoService extends Effect.Service<FilterRepoService>()(
|
|
|
79
89
|
),
|
|
80
90
|
)
|
|
81
91
|
|
|
92
|
+
verboseLog(`git-filter-repo exited with code ${result.exitCode}`)
|
|
93
|
+
|
|
82
94
|
if (result.exitCode !== 0) {
|
|
83
95
|
return yield* Effect.fail(
|
|
84
96
|
new FilterRepoError({
|
|
@@ -8,6 +8,7 @@ export interface CapturedFilterRepoCall {
|
|
|
8
8
|
gitRoot: string
|
|
9
9
|
args: readonly string[]
|
|
10
10
|
env?: Record<string, string>
|
|
11
|
+
streamOutput?: boolean
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
/**
|
|
@@ -63,6 +64,8 @@ export class MockFilterRepoService extends Effect.Service<MockFilterRepoService>
|
|
|
63
64
|
args: readonly string[],
|
|
64
65
|
options?: {
|
|
65
66
|
readonly env?: Record<string, string>
|
|
67
|
+
readonly verboseLog?: (message: string) => void
|
|
68
|
+
readonly streamOutput?: boolean
|
|
66
69
|
},
|
|
67
70
|
) => {
|
|
68
71
|
// Capture the call
|
|
@@ -70,6 +73,7 @@ export class MockFilterRepoService extends Effect.Service<MockFilterRepoService>
|
|
|
70
73
|
gitRoot,
|
|
71
74
|
args,
|
|
72
75
|
env: options?.env,
|
|
76
|
+
streamOutput: options?.streamOutput,
|
|
73
77
|
})
|
|
74
78
|
|
|
75
79
|
// Return a successful result
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { Effect } from "effect"
|
|
3
|
+
import { spawnProcess } from "./process"
|
|
4
|
+
|
|
5
|
+
describe("spawnProcess", () => {
|
|
6
|
+
test("captures large stdout and stderr without hanging", async () => {
|
|
7
|
+
const line = "x".repeat(4096)
|
|
8
|
+
const script = [
|
|
9
|
+
`const line = ${JSON.stringify(line)}`,
|
|
10
|
+
"for (let i = 0; i < 2000; i++) {",
|
|
11
|
+
"console.log(`out:${i}:${line}`)",
|
|
12
|
+
"console.error(`err:${i}:${line}`)",
|
|
13
|
+
"}",
|
|
14
|
+
].join("\n")
|
|
15
|
+
|
|
16
|
+
const result = await Effect.runPromise(
|
|
17
|
+
spawnProcess([process.execPath, "-e", script]),
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
expect(result.exitCode).toBe(0)
|
|
21
|
+
expect(result.stdout).toContain("out:0:")
|
|
22
|
+
expect(result.stdout).toContain("out:1999:")
|
|
23
|
+
expect(result.stderr).toContain("err:0:")
|
|
24
|
+
expect(result.stderr).toContain("err:1999:")
|
|
25
|
+
})
|
|
26
|
+
})
|
package/src/utils/process.ts
CHANGED
|
@@ -55,21 +55,28 @@ export const spawnProcess = (
|
|
|
55
55
|
env: options?.env ? { ...process.env, ...options.env } : process.env,
|
|
56
56
|
})
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const
|
|
58
|
+
// Start draining stdout/stderr immediately so verbose subprocesses
|
|
59
|
+
// cannot block on filled pipe buffers before they exit.
|
|
60
|
+
const stdoutPromise =
|
|
61
61
|
options?.stdout === "inherit"
|
|
62
|
-
? ""
|
|
63
|
-
:
|
|
64
|
-
const
|
|
62
|
+
? Promise.resolve("")
|
|
63
|
+
: new Response(proc.stdout ?? "").text()
|
|
64
|
+
const stderrPromise =
|
|
65
65
|
options?.stderr === "inherit"
|
|
66
|
-
? ""
|
|
67
|
-
:
|
|
66
|
+
? Promise.resolve("")
|
|
67
|
+
: new Response(proc.stderr ?? "").text()
|
|
68
|
+
|
|
69
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
70
|
+
proc.exited,
|
|
71
|
+
stdoutPromise,
|
|
72
|
+
stderrPromise,
|
|
73
|
+
])
|
|
68
74
|
|
|
69
75
|
return {
|
|
70
76
|
stdout: stdout.trim(),
|
|
71
77
|
stderr: stderr.trim(),
|
|
72
|
-
exitCode:
|
|
78
|
+
exitCode:
|
|
79
|
+
typeof exitCode === "number" ? exitCode : (proc.exitCode ?? 0),
|
|
73
80
|
}
|
|
74
81
|
},
|
|
75
82
|
catch: (error) =>
|