@markjaquith/agency 1.11.0 → 1.11.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
@@ -178,7 +178,7 @@ const commands: Record<string, Command> = {
178
178
  },
179
179
  push: {
180
180
  name: "push",
181
- description: "Emit, push to remote, return to source",
181
+ description: "Push the emitted branch or fall through to git push",
182
182
  run: async (args: string[], options: Record<string, any>) => {
183
183
  if (options.help) {
184
184
  console.log(pushHelp)
@@ -187,9 +187,11 @@ const commands: Record<string, Command> = {
187
187
  await runCommand(
188
188
  push({
189
189
  baseBranch: args[0],
190
+ gitArgs: args,
190
191
  emit: options.emit || options.branch,
191
192
  silent: options.silent,
192
193
  force: options.force,
194
+ forceWithLease: options["force-with-lease"],
193
195
  noVerify: options["no-verify"],
194
196
  verbose: options.verbose,
195
197
  pr: options.pr,
@@ -508,7 +510,7 @@ Commands:
508
510
  emit [base-branch] Emit a branch with backpack files reverted
509
511
  emitted Get the name of the emitted branch
510
512
  pr <subcommand> Run gh pr with the emitted branch name
511
- push [base-branch] Emit, push to remote, return to source
513
+ push [arguments] Push the emitted branch or fall through to git push
512
514
  pull Pull commits from remote emit branch to source
513
515
  rebase [base-branch] Rebase source branch onto base branch
514
516
  base Get or set the base branch
@@ -611,6 +613,9 @@ try {
611
613
  type: "boolean",
612
614
  short: "f",
613
615
  },
616
+ "force-with-lease": {
617
+ type: "boolean",
618
+ },
614
619
  "no-verify": {
615
620
  type: "boolean",
616
621
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "1.11.0",
3
+ "version": "1.11.1",
4
4
  "description": "Manages personal agents files",
5
5
  "keywords": [
6
6
  "agents",
@@ -40,6 +40,21 @@ async function setupBareRemote(): Promise<string> {
40
40
  return remoteDir
41
41
  }
42
42
 
43
+ async function checkoutNonAgencyBranch(gitRoot: string): Promise<void> {
44
+ const proc = Bun.spawn(
45
+ ["git", "checkout", "-q", "-b", "plain-feature", "origin/main"],
46
+ {
47
+ cwd: gitRoot,
48
+ stdout: "pipe",
49
+ stderr: "pipe",
50
+ },
51
+ )
52
+ const exitCode = await proc.exited
53
+ if (exitCode !== 0) {
54
+ throw new Error(await new Response(proc.stderr).text())
55
+ }
56
+ }
57
+
43
58
  describe("push command", () => {
44
59
  let tempDir: string
45
60
  let remoteDir: string
@@ -92,6 +107,114 @@ describe("push command", () => {
92
107
  })
93
108
 
94
109
  describe("basic functionality", () => {
110
+ test("falls back to git push outside agency context", async () => {
111
+ await checkoutNonAgencyBranch(tempDir)
112
+ await createCommit(tempDir, "Plain feature work")
113
+ await Bun.write(join(tempDir, "uncommitted.txt"), "uncommitted\n")
114
+
115
+ await runTestEffect(
116
+ push({
117
+ gitArgs: ["origin", "plain-feature"],
118
+ silent: true,
119
+ }),
120
+ )
121
+
122
+ const localHead = await getGitOutput(tempDir, ["rev-parse", "HEAD"])
123
+ const remoteHead = await getGitOutput(tempDir, [
124
+ "rev-parse",
125
+ "refs/remotes/origin/plain-feature",
126
+ ])
127
+ expect(remoteHead.trim()).toBe(localHead.trim())
128
+ expect(await Bun.file(join(tempDir, "uncommitted.txt")).exists()).toBe(
129
+ true,
130
+ )
131
+ })
132
+
133
+ test("forwards --no-verify to the git push fallback", async () => {
134
+ await checkoutNonAgencyBranch(tempDir)
135
+ await createCommit(tempDir, "Plain feature work")
136
+
137
+ await Bun.spawn(
138
+ ["git", "config", "core.hooksPath", join(tempDir, ".git", "hooks")],
139
+ {
140
+ cwd: tempDir,
141
+ stdout: "pipe",
142
+ stderr: "pipe",
143
+ },
144
+ ).exited
145
+ const hookPath = join(tempDir, ".git", "hooks", "pre-push")
146
+ await Bun.write(hookPath, "#!/bin/sh\nexit 1\n")
147
+ await Bun.spawn(["chmod", "+x", hookPath], {
148
+ cwd: tempDir,
149
+ stdout: "pipe",
150
+ stderr: "pipe",
151
+ }).exited
152
+
153
+ await runTestEffect(
154
+ push({
155
+ gitArgs: ["origin", "plain-feature"],
156
+ noVerify: true,
157
+ silent: true,
158
+ }),
159
+ )
160
+
161
+ const remoteBranches = await getGitOutput(tempDir, [
162
+ "ls-remote",
163
+ "--heads",
164
+ "origin",
165
+ "plain-feature",
166
+ ])
167
+ expect(remoteBranches).toContain("plain-feature")
168
+ })
169
+
170
+ test("forwards force modes to the git push fallback", async () => {
171
+ await checkoutNonAgencyBranch(tempDir)
172
+ await createCommit(tempDir, "Initial plain feature work")
173
+ await Bun.spawn(["git", "push", "-u", "origin", "plain-feature"], {
174
+ cwd: tempDir,
175
+ stdout: "pipe",
176
+ stderr: "pipe",
177
+ }).exited
178
+
179
+ await createCommit(tempDir, "Remote state for lease")
180
+ await Bun.spawn(["git", "push"], {
181
+ cwd: tempDir,
182
+ stdout: "pipe",
183
+ stderr: "pipe",
184
+ }).exited
185
+ await Bun.spawn(["git", "reset", "--hard", "HEAD~1"], {
186
+ cwd: tempDir,
187
+ stdout: "pipe",
188
+ stderr: "pipe",
189
+ }).exited
190
+ await createCommit(tempDir, "Lease-protected rewrite")
191
+
192
+ await runTestEffect(push({ forceWithLease: true, silent: true }))
193
+
194
+ await createCommit(tempDir, "Remote state for force")
195
+ await Bun.spawn(["git", "push"], {
196
+ cwd: tempDir,
197
+ stdout: "pipe",
198
+ stderr: "pipe",
199
+ }).exited
200
+ await Bun.spawn(["git", "reset", "--hard", "HEAD~1"], {
201
+ cwd: tempDir,
202
+ stdout: "pipe",
203
+ stderr: "pipe",
204
+ }).exited
205
+ await createCommit(tempDir, "Forced rewrite")
206
+
207
+ await runTestEffect(push({ force: true, silent: true }))
208
+
209
+ const localHead = await getGitOutput(tempDir, ["rev-parse", "HEAD"])
210
+ const remoteHead = await getGitOutput(tempDir, [
211
+ "ls-remote",
212
+ "origin",
213
+ "refs/heads/plain-feature",
214
+ ])
215
+ expect(remoteHead.split("\t")[0]).toBe(localHead.trim())
216
+ })
217
+
95
218
  test("fails when there are uncommitted changes", async () => {
96
219
  // Create uncommitted changes
97
220
  await Bun.write(join(tempDir, "uncommitted.txt"), "uncommitted\n")
@@ -2,7 +2,7 @@ import { Effect, Either } from "effect"
2
2
  import type { BaseCommandOptions } from "../utils/command"
3
3
  import { GitCommandError, GitService } from "../services/GitService"
4
4
  import { ConfigService } from "../services/ConfigService"
5
- import { resolveBranchPairWithAgencyJson } from "../utils/pr-branch"
5
+ import { resolveAgencyBranchPairWithAgencyJson } from "../utils/pr-branch"
6
6
  import { emit } from "./emit"
7
7
  import highlight, { done } from "../utils/colors"
8
8
  import {
@@ -16,14 +16,54 @@ import { spawnProcess } from "../utils/process"
16
16
 
17
17
  interface PushOptions extends BaseCommandOptions {
18
18
  baseBranch?: string
19
+ gitArgs?: string[]
19
20
  emit?: string
20
21
  branch?: string // Deprecated: use emit instead
21
22
  force?: boolean
23
+ forceWithLease?: boolean
22
24
  noVerify?: boolean
23
25
  pr?: boolean
24
26
  skipFilter?: boolean
25
27
  }
26
28
 
29
+ const getFallbackGitPushArgs = (options: PushOptions): string[] => {
30
+ const args: string[] = []
31
+
32
+ if (options.force) {
33
+ args.push("--force")
34
+ }
35
+ if (options.forceWithLease) {
36
+ args.push("--force-with-lease")
37
+ }
38
+ if (options.noVerify) {
39
+ args.push("--no-verify")
40
+ }
41
+
42
+ args.push(...(options.gitArgs ?? []))
43
+ return args
44
+ }
45
+
46
+ const pushWithGit = (gitRoot: string, options: PushOptions) =>
47
+ Effect.gen(function* () {
48
+ const args = ["git", "push", ...getFallbackGitPushArgs(options)]
49
+ const result = yield* spawnProcess(args, {
50
+ cwd: gitRoot,
51
+ stdin: "inherit",
52
+ stdout: options.silent ? "pipe" : "inherit",
53
+ stderr: options.silent ? "pipe" : "inherit",
54
+ })
55
+
56
+ if (result.exitCode !== 0) {
57
+ return yield* Effect.fail(
58
+ new GitCommandError({
59
+ command: args.join(" "),
60
+ exitCode: result.exitCode,
61
+ stderr: result.stderr,
62
+ }),
63
+ )
64
+ }
65
+ })
66
+
27
67
  const getPushFailureStderr = (error: unknown): string => {
28
68
  if (error instanceof GitCommandError) {
29
69
  return error.stderr.trim()
@@ -91,7 +131,26 @@ const pushCore = (gitRoot: string, options: PushOptions) =>
91
131
  const git = yield* GitService
92
132
  const configService = yield* ConfigService
93
133
 
94
- // Check for uncommitted changes
134
+ // Load config to check emit branch pattern
135
+ const config = yield* configService.loadConfig()
136
+
137
+ // Get current branch
138
+ let sourceBranch = yield* git.getCurrentBranch(gitRoot)
139
+
140
+ // Check if we're already on an emit branch using proper branch resolution
141
+ const branchInfo = yield* resolveAgencyBranchPairWithAgencyJson(
142
+ gitRoot,
143
+ sourceBranch,
144
+ config.sourceBranchPattern,
145
+ config.emitBranch,
146
+ )
147
+
148
+ if (!branchInfo) {
149
+ verboseLog("Outside agency branch context; falling back to git push")
150
+ return yield* pushWithGit(gitRoot, options)
151
+ }
152
+
153
+ // Agency push performs internal checkouts, so require a clean worktree.
95
154
  const statusOutput = yield* git.getStatus(gitRoot)
96
155
 
97
156
  if (statusOutput && statusOutput.trim().length > 0) {
@@ -106,20 +165,6 @@ const pushCore = (gitRoot: string, options: PushOptions) =>
106
165
 
107
166
  verboseLog(`Working directory is clean`)
108
167
 
109
- // Load config to check emit branch pattern
110
- const config = yield* configService.loadConfig()
111
-
112
- // Get current branch
113
- let sourceBranch = yield* git.getCurrentBranch(gitRoot)
114
-
115
- // Check if we're already on an emit branch using proper branch resolution
116
- const branchInfo = yield* resolveBranchPairWithAgencyJson(
117
- gitRoot,
118
- sourceBranch,
119
- config.sourceBranchPattern,
120
- config.emitBranch,
121
- )
122
-
123
168
  // If we're on an emit branch, switch to the source branch first
124
169
  if (branchInfo.isOnEmitBranch) {
125
170
  const actualSourceBranch = branchInfo.sourceBranch
@@ -188,13 +233,15 @@ const pushCore = (gitRoot: string, options: PushOptions) =>
188
233
  withSpinner(
189
234
  pushBranchToRemoteEffect(gitRoot, emitBranchName, remote, {
190
235
  force: options.force,
236
+ forceWithLease: options.forceWithLease,
191
237
  noVerify: options.noVerify,
192
238
  verbose: options.verbose,
193
239
  }),
194
240
  {
195
- text: options.force
196
- ? `Pushing to ${highlight.remote(remote)} (forced)`
197
- : `Pushing to ${highlight.remote(remote)}`,
241
+ text:
242
+ options.force || options.forceWithLease
243
+ ? `Pushing to ${highlight.remote(remote)} (forced)`
244
+ : `Pushing to ${highlight.remote(remote)}`,
198
245
  enabled: !options.silent && !options.verbose,
199
246
  },
200
247
  ),
@@ -246,13 +293,14 @@ const pushBranchToRemoteEffect = (
246
293
  remote: string,
247
294
  options: {
248
295
  readonly force?: boolean
296
+ readonly forceWithLease?: boolean
249
297
  readonly noVerify?: boolean
250
298
  readonly verbose?: boolean
251
299
  },
252
300
  ) =>
253
301
  Effect.gen(function* () {
254
302
  const git = yield* GitService
255
- const { force = false, noVerify = false } = options
303
+ const { force = false, forceWithLease = false, noVerify = false } = options
256
304
 
257
305
  // Try pushing without force first
258
306
  const pushResult = yield* git
@@ -276,12 +324,13 @@ const pushBranchToRemoteEffect = (
276
324
  // Check if this is a force-push-needed error
277
325
  const needsForce = pushFailureNeedsForce(stderr)
278
326
 
279
- if (needsForce && force) {
280
- // User provided --force flag, retry with force
327
+ if (needsForce && (force || forceWithLease)) {
328
+ // Retry using the force mode explicitly requested by the user.
281
329
  const forceResult = yield* git
282
330
  .push(gitRoot, remote, branchName, {
283
331
  setUpstream: true,
284
- force: true,
332
+ force,
333
+ forceWithLease,
285
334
  noVerify,
286
335
  })
287
336
  .pipe(
@@ -303,7 +352,7 @@ const pushBranchToRemoteEffect = (
303
352
  }
304
353
 
305
354
  usedForce = true
306
- } else if (needsForce && !force) {
355
+ } else if (needsForce) {
307
356
  // User didn't provide --force but it's needed
308
357
  return yield* Effect.fail(
309
358
  formatPushFailure(
@@ -386,9 +435,10 @@ const openGitHubPR = (
386
435
  })
387
436
 
388
437
  export const help = `
389
- Usage: agency push [base-branch] [options]
438
+ Usage: agency push [arguments] [options]
390
439
 
391
- Create a emit branch, push it to remote, and return to the source branch.
440
+ Create an emit branch, push it to remote, and return to the source branch.
441
+ Outside agency branch context, fall through to plain 'git push'.
392
442
 
393
443
  This command is a convenience wrapper that runs operations in sequence:
394
444
  1. agency emit [base-branch] - Create emit branch with backpack files reverted
@@ -404,31 +454,29 @@ Base Branch Selection:
404
454
  Same as 'agency emit' - see 'agency emit --help' for details
405
455
 
406
456
  Prerequisites:
407
- - git-filter-repo must be installed: brew install git-filter-repo
408
- - Remote 'origin' must be configured
457
+ - Agency branch pushes require git-filter-repo: brew install git-filter-repo
458
+ - A Git remote must be configured
409
459
 
410
460
  Arguments:
411
- base-branch Base branch to compare against (e.g., origin/main)
412
- If not provided, will use saved config or auto-detect
461
+ arguments In agency context, the first argument is the base branch.
462
+ Otherwise, arguments are passed to git push as remote/refspecs.
413
463
 
414
464
  Options:
415
465
  --emit Custom name for emit branch (defaults to pattern from config)
416
- --branch (Deprecated: use --emit) Custom name for emit branch
417
- -f, --force Force push to remote if branch has diverged
418
- --no-verify Bypass git pre-push hooks
419
- --pr Open GitHub PR in browser after pushing (requires gh CLI)
466
+ --branch (Deprecated: use --emit) Custom name for emit branch
467
+ -f, --force Force push to remote if branch has diverged
468
+ --force-with-lease Force push only if the remote branch is unchanged
469
+ --no-verify Bypass git pre-push hooks
470
+ --pr Open GitHub PR in browser after pushing (requires gh CLI)
420
471
 
421
472
  Examples:
422
- agency push # Create PR, push, return to source
423
- agency push origin/main # Explicitly use origin/main as base
424
- agency push --force # Force push if branch has diverged
425
- agency push --no-verify # Push without running pre-push hooks
426
- agency push --pr # Push and open GitHub PR in browser
473
+ agency push # Push the emitted branch or current branch
474
+ agency push origin/main # Use an explicit base in agency context
475
+ agency push origin feature # Pass remote/refspec outside agency context
427
476
 
428
477
  Notes:
429
- - Must be run from a source branch (not a emit branch)
430
- - Creates or recreates the emit branch
431
- - Pushes with -u flag to set up tracking
432
- - Automatically returns to source branch after pushing
478
+ - Outside agency context, positional arguments and --force, --force-with-lease, and --no-verify are passed to git push
479
+ - In agency context, creates or recreates the emit branch and pushes it with -u
480
+ - In agency context, automatically returns to the source branch after pushing
433
481
  - If any step fails, the command stops and reports the error
434
482
  `
@@ -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 forceWithLease?: boolean
1049
1050
  readonly noVerify?: boolean
1050
1051
  },
1051
1052
  ) => {
@@ -1056,6 +1057,9 @@ export class GitService extends Effect.Service<GitService>()("GitService", {
1056
1057
  if (options?.force) {
1057
1058
  args.push("--force")
1058
1059
  }
1060
+ if (options?.forceWithLease) {
1061
+ args.push("--force-with-lease")
1062
+ }
1059
1063
  if (options?.noVerify) {
1060
1064
  args.push("--no-verify")
1061
1065
  }