@markjaquith/agency 1.8.4 → 1.8.6

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.8.4",
3
+ "version": "1.8.6",
4
4
  "description": "Manages personal agents files",
5
5
  "keywords": [
6
6
  "agents",
@@ -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
  })
@@ -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
- // Clean up .git/filter-repo directory
190
- const filterRepoDir = `${gitRoot}/.git/filter-repo`
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
  *
@@ -306,7 +306,9 @@ describe("push command", () => {
306
306
  runTestEffect(
307
307
  push({ baseBranch: "main", silent: true, skipFilter: true }),
308
308
  ),
309
- ).rejects.toThrow(/agency push --force/)
309
+ ).rejects.toThrow(
310
+ /non-fast-forward update.*agency push --force|agency push --force.*non-fast-forward update/s,
311
+ )
310
312
 
311
313
  // Should still be on agency--feature branch (not left in intermediate state)
312
314
  expect(await getCurrentBranch(tempDir)).toBe("agency--feature")
@@ -1,13 +1,8 @@
1
1
  import { Effect, Either } from "effect"
2
2
  import type { BaseCommandOptions } from "../utils/command"
3
- import { GitService } from "../services/GitService"
3
+ import { GitCommandError, GitService } from "../services/GitService"
4
4
  import { ConfigService } from "../services/ConfigService"
5
- import {
6
- extractSourceBranch,
7
- makePrBranchName,
8
- resolveBranchPairWithAgencyJson,
9
- } from "../utils/pr-branch"
10
- import { FileSystemService } from "../services/FileSystemService"
5
+ import { resolveBranchPairWithAgencyJson } from "../utils/pr-branch"
11
6
  import { emit } from "./emit"
12
7
  import highlight, { done } from "../utils/colors"
13
8
  import {
@@ -28,6 +23,51 @@ interface PushOptions extends BaseCommandOptions {
28
23
  skipFilter?: boolean
29
24
  }
30
25
 
26
+ const getPushFailureStderr = (error: unknown): string => {
27
+ if (error instanceof GitCommandError) {
28
+ return error.stderr.trim()
29
+ }
30
+
31
+ if (
32
+ typeof error === "object" &&
33
+ error !== null &&
34
+ "cause" in error &&
35
+ (error as { cause?: unknown }).cause instanceof GitCommandError
36
+ ) {
37
+ return (error as { cause: GitCommandError }).cause.stderr.trim()
38
+ }
39
+
40
+ if (error instanceof Error) {
41
+ return error.message.trim()
42
+ }
43
+
44
+ return String(error).trim()
45
+ }
46
+
47
+ const pushFailureNeedsForce = (stderr: string): boolean => {
48
+ const normalized = stderr.toLowerCase()
49
+
50
+ return [
51
+ "non-fast-forward",
52
+ "updates were rejected because the tip of your current branch is behind",
53
+ "fetch first",
54
+ "failed to push some refs",
55
+ ].some((pattern) => normalized.includes(pattern))
56
+ }
57
+
58
+ const formatPushFailure = (error: unknown): Error => {
59
+ const stderr = getPushFailureStderr(error)
60
+
61
+ if (pushFailureNeedsForce(stderr)) {
62
+ return new Error(
63
+ "Failed to push branch to remote: the remote rejected a non-fast-forward update, which usually means the branch was rebased or rewritten.\n" +
64
+ "Run `agency push --force` to replace the remote branch.",
65
+ )
66
+ }
67
+
68
+ return new Error(`Failed to push branch to remote: ${stderr}`)
69
+ }
70
+
31
71
  export const push = (options: PushOptions = {}) =>
32
72
  Effect.gen(function* () {
33
73
  const gitRoot = yield* ensureGitRepo()
@@ -39,7 +79,6 @@ export const push = (options: PushOptions = {}) =>
39
79
 
40
80
  const pushCore = (gitRoot: string, options: PushOptions) =>
41
81
  Effect.gen(function* () {
42
- const { verbose = false } = options
43
82
  const { log, verboseLog } = createLoggers(options)
44
83
 
45
84
  const git = yield* GitService
@@ -52,7 +91,6 @@ const pushCore = (gitRoot: string, options: PushOptions) =>
52
91
  let sourceBranch = yield* git.getCurrentBranch(gitRoot)
53
92
 
54
93
  // Check if we're already on an emit branch using proper branch resolution
55
- const fs = yield* FileSystemService
56
94
  const branchInfo = yield* resolveBranchPairWithAgencyJson(
57
95
  gitRoot,
58
96
  sourceBranch,
@@ -153,7 +191,7 @@ const pushCore = (gitRoot: string, options: PushOptions) =>
153
191
  ),
154
192
  )
155
193
  if (Either.isLeft(pushEither)) {
156
- const error = pushEither.left
194
+ const error = formatPushFailure(pushEither.left)
157
195
  // If push failed, switch back to source branch
158
196
  yield* git.checkoutBranch(gitRoot, sourceBranch)
159
197
  return yield* Effect.fail(error)
@@ -226,11 +264,7 @@ const pushBranchToRemoteEffect = (
226
264
  const stderr = pushResult.stderr
227
265
 
228
266
  // Check if this is a force-push-needed error
229
- const needsForce =
230
- stderr.includes("rejected") ||
231
- stderr.includes("non-fast-forward") ||
232
- stderr.includes("fetch first") ||
233
- stderr.includes("Updates were rejected")
267
+ const needsForce = pushFailureNeedsForce(stderr)
234
268
 
235
269
  if (needsForce && force) {
236
270
  // User provided --force flag, retry with force
@@ -258,15 +292,24 @@ const pushBranchToRemoteEffect = (
258
292
  } else if (needsForce && !force) {
259
293
  // User didn't provide --force but it's needed
260
294
  return yield* Effect.fail(
261
- new Error(
262
- `Failed to push branch to remote. The branch has diverged from the remote.\n` +
263
- `Run 'agency push --force' to force push the branch.`,
295
+ formatPushFailure(
296
+ new GitCommandError({
297
+ command: `git push -u ${remote} ${branchName}`,
298
+ exitCode: pushResult.exitCode,
299
+ stderr,
300
+ }),
264
301
  ),
265
302
  )
266
303
  } else {
267
304
  // Some other error
268
305
  return yield* Effect.fail(
269
- new Error(`Failed to push branch to remote: ${stderr}`),
306
+ formatPushFailure(
307
+ new GitCommandError({
308
+ command: `git push -u ${remote} ${branchName}`,
309
+ exitCode: pushResult.exitCode,
310
+ stderr,
311
+ }),
312
+ ),
270
313
  )
271
314
  }
272
315
  }
@@ -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,6 @@
1
+ import { isAbsolute, resolve } from "path"
2
+
3
+ export const resolveGitInternalPath = (
4
+ gitRoot: string,
5
+ gitPath: string,
6
+ ): string => (isAbsolute(gitPath) ? gitPath : resolve(gitRoot, gitPath))
@@ -439,24 +439,3 @@ export const resolveBranchPairWithAgencyJson = (
439
439
  // Strategy 4: Fall back to pattern-based resolution
440
440
  return resolveBranchPair(currentBranch, sourcePattern, emitPattern)
441
441
  })
442
-
443
- // Legacy function names for backward compatibility
444
- // These will be updated as we migrate the codebase
445
-
446
- /**
447
- * @deprecated Use makeEmitBranchName instead. This function now creates emit branches,
448
- * not PR branches with suffixes.
449
- */
450
- export function makePrBranchName(branchName: string, pattern: string): string {
451
- return makeEmitBranchName(branchName, pattern)
452
- }
453
-
454
- /**
455
- * @deprecated Use extractCleanFromEmit instead. Extracts clean branch from emit branch.
456
- */
457
- export function extractSourceBranch(
458
- emitBranchName: string,
459
- pattern: string,
460
- ): string | null {
461
- return extractCleanFromEmit(emitBranchName, pattern)
462
- }
@@ -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
+ })
@@ -55,21 +55,28 @@ export const spawnProcess = (
55
55
  env: options?.env ? { ...process.env, ...options.env } : process.env,
56
56
  })
57
57
 
58
- await proc.exited
59
-
60
- const stdout =
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
- : await new Response(proc.stdout).text()
64
- const stderr =
62
+ ? Promise.resolve("")
63
+ : new Response(proc.stdout ?? "").text()
64
+ const stderrPromise =
65
65
  options?.stderr === "inherit"
66
- ? ""
67
- : await new Response(proc.stderr).text()
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: proc.exitCode ?? 0,
78
+ exitCode:
79
+ typeof exitCode === "number" ? exitCode : (proc.exitCode ?? 0),
73
80
  }
74
81
  },
75
82
  catch: (error) =>
@@ -74,6 +74,7 @@ export const withSpinner = <A, E, R>(
74
74
  if (failText) {
75
75
  spinner.fail(failText)
76
76
  } else {
77
+ spinner.clear()
77
78
  spinner.stop()
78
79
  }
79
80
  throw error