@markjaquith/agency 1.10.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/README.md CHANGED
@@ -104,6 +104,31 @@ Toggle between source branch and emit branch. If on an emit branch (e.g., `foo--
104
104
 
105
105
  Switch to the source branch for the current emit branch.
106
106
 
107
+ ### `agency completions <bash|zsh>`
108
+
109
+ Generate shell completion scripts. The generated scripts are static, so shell startup and tab completion do not call `agency`.
110
+
111
+ **zsh:**
112
+
113
+ ```bash
114
+ mkdir -p ~/.zsh/completions
115
+ agency completions zsh > ~/.zsh/completions/_agency
116
+ ```
117
+
118
+ Then add this to `~/.zshrc` before `compinit`:
119
+
120
+ ```bash
121
+ fpath=(~/.zsh/completions $fpath)
122
+ autoload -Uz compinit && compinit
123
+ ```
124
+
125
+ **bash:**
126
+
127
+ ```bash
128
+ mkdir -p ~/.local/share/bash-completion/completions
129
+ agency completions bash > ~/.local/share/bash-completion/completions/agency
130
+ ```
131
+
107
132
  ## Requirements
108
133
 
109
134
  - [Bun](https://bun.sh) >= 1.0.0 (recommended)
package/cli.ts CHANGED
@@ -20,6 +20,10 @@ import { template, help as templateHelp } from "./src/commands/template"
20
20
  import { work, help as workHelp } from "./src/commands/work"
21
21
  import { loop, help as loopHelp } from "./src/commands/loop"
22
22
  import { status, help as statusHelp } from "./src/commands/status"
23
+ import {
24
+ completions,
25
+ help as completionsHelp,
26
+ } from "./src/commands/completions"
23
27
  import type { Command } from "./src/types"
24
28
  import { setColorsEnabled } from "./src/utils/colors"
25
29
  import { GitService } from "./src/services/GitService"
@@ -174,7 +178,7 @@ const commands: Record<string, Command> = {
174
178
  },
175
179
  push: {
176
180
  name: "push",
177
- description: "Emit, push to remote, return to source",
181
+ description: "Push the emitted branch or fall through to git push",
178
182
  run: async (args: string[], options: Record<string, any>) => {
179
183
  if (options.help) {
180
184
  console.log(pushHelp)
@@ -183,9 +187,11 @@ const commands: Record<string, Command> = {
183
187
  await runCommand(
184
188
  push({
185
189
  baseBranch: args[0],
190
+ gitArgs: args,
186
191
  emit: options.emit || options.branch,
187
192
  silent: options.silent,
188
193
  force: options.force,
194
+ forceWithLease: options["force-with-lease"],
189
195
  noVerify: options["no-verify"],
190
196
  verbose: options.verbose,
191
197
  pr: options.pr,
@@ -469,6 +475,18 @@ const commands: Record<string, Command> = {
469
475
  },
470
476
  help: loopHelp,
471
477
  },
478
+ completions: {
479
+ name: "completions",
480
+ description: "Generate shell completion scripts",
481
+ run: async (args: string[], options: Record<string, any>) => {
482
+ if (options.help) {
483
+ console.log(completionsHelp)
484
+ return
485
+ }
486
+ await runCommand(completions(args[0]))
487
+ },
488
+ help: completionsHelp,
489
+ },
472
490
  }
473
491
 
474
492
  function showMainHelp() {
@@ -492,7 +510,7 @@ Commands:
492
510
  emit [base-branch] Emit a branch with backpack files reverted
493
511
  emitted Get the name of the emitted branch
494
512
  pr <subcommand> Run gh pr with the emitted branch name
495
- push [base-branch] Emit, push to remote, return to source
513
+ push [arguments] Push the emitted branch or fall through to git push
496
514
  pull Pull commits from remote emit branch to source
497
515
  rebase [base-branch] Rebase source branch onto base branch
498
516
  base Get or set the base branch
@@ -502,6 +520,7 @@ Commands:
502
520
  source Switch to source branch from emitted branch
503
521
  merge Merge emitted branch into base branch
504
522
  status Show agency status for this repository
523
+ completions <shell> Generate bash or zsh completion scripts
505
524
 
506
525
  Global Options:
507
526
  -h, --help Show help for a command
@@ -594,6 +613,9 @@ try {
594
613
  type: "boolean",
595
614
  short: "f",
596
615
  },
616
+ "force-with-lease": {
617
+ type: "boolean",
618
+ },
597
619
  "no-verify": {
598
620
  type: "boolean",
599
621
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "1.10.0",
3
+ "version": "1.11.1",
4
4
  "description": "Manages personal agents files",
5
5
  "keywords": [
6
6
  "agents",
@@ -0,0 +1,51 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { Effect } from "effect"
3
+ import { completions } from "./completions"
4
+
5
+ const captureCompletions = async (shell: string) => {
6
+ const originalLog = console.log
7
+ let output = ""
8
+ console.log = (message: string) => {
9
+ output += message
10
+ }
11
+
12
+ try {
13
+ await Effect.runPromise(completions(shell))
14
+ } finally {
15
+ console.log = originalLog
16
+ }
17
+
18
+ return output
19
+ }
20
+
21
+ describe("completions command", () => {
22
+ test("generates bash completions", async () => {
23
+ const output = await captureCompletions("bash")
24
+
25
+ expect(output).toContain("_agency_completions()")
26
+ expect(output).toContain("complete -F _agency_completions agency")
27
+ expect(output).toContain("completions")
28
+ expect(output).toContain("bash zsh")
29
+ })
30
+
31
+ test("generates zsh completions", async () => {
32
+ const output = await captureCompletions("zsh")
33
+
34
+ expect(output).toContain("#compdef agency")
35
+ expect(output).toContain("_agency()")
36
+ expect(output).toContain("agency_commands=(")
37
+ expect(output).toContain("bash\\:Generate\\ bash\\ completions")
38
+ })
39
+
40
+ test("prints the requested completion script", async () => {
41
+ const output = await captureCompletions("zsh")
42
+
43
+ expect(output).toContain("#compdef agency")
44
+ })
45
+
46
+ test("rejects unsupported shells", async () => {
47
+ await expect(Effect.runPromise(completions("fish"))).rejects.toThrow(
48
+ "Usage: agency completions <bash|zsh>",
49
+ )
50
+ })
51
+ })
@@ -0,0 +1,308 @@
1
+ import { Effect } from "effect"
2
+
3
+ const shells = ["bash", "zsh"] as const
4
+ type Shell = (typeof shells)[number]
5
+
6
+ const commands = [
7
+ "init",
8
+ "task",
9
+ "tasks",
10
+ "edit",
11
+ "work",
12
+ "template",
13
+ "emit",
14
+ "emitted",
15
+ "pr",
16
+ "push",
17
+ "pull",
18
+ "rebase",
19
+ "base",
20
+ "switch",
21
+ "source",
22
+ "merge",
23
+ "status",
24
+ "clean",
25
+ "loop",
26
+ "completions",
27
+ ] as const
28
+
29
+ const templateSubcommands = ["use", "save", "list", "view", "delete"] as const
30
+ const baseSubcommands = ["get", "set"] as const
31
+ const completionSubcommands = shells
32
+
33
+ const globalOptions = [
34
+ "--help",
35
+ "-h",
36
+ "--version",
37
+ "-v",
38
+ "--no-color",
39
+ "--silent",
40
+ "-s",
41
+ "--verbose",
42
+ ] as const
43
+
44
+ const commandOptions: Record<string, readonly string[]> = {
45
+ init: [
46
+ "--help",
47
+ "-h",
48
+ "--template",
49
+ "-t",
50
+ "--silent",
51
+ "-s",
52
+ "--verbose",
53
+ "-v",
54
+ ],
55
+ emit: [
56
+ "--help",
57
+ "-h",
58
+ "--emit",
59
+ "--branch",
60
+ "--silent",
61
+ "-s",
62
+ "--verbose",
63
+ "-v",
64
+ ],
65
+ push: [
66
+ "--help",
67
+ "-h",
68
+ "--emit",
69
+ "--branch",
70
+ "--force",
71
+ "-f",
72
+ "--no-verify",
73
+ "--pr",
74
+ "--silent",
75
+ "-s",
76
+ "--verbose",
77
+ "-v",
78
+ ],
79
+ pull: ["--help", "-h", "--remote", "-r", "--silent", "-s", "--verbose", "-v"],
80
+ rebase: [
81
+ "--help",
82
+ "-h",
83
+ "--emit",
84
+ "--branch",
85
+ "--silent",
86
+ "-s",
87
+ "--verbose",
88
+ "-v",
89
+ ],
90
+ task: [
91
+ "--help",
92
+ "-h",
93
+ "--emit",
94
+ "--branch",
95
+ "--task",
96
+ "--from",
97
+ "--from-current",
98
+ "--continue",
99
+ "--squash",
100
+ "--silent",
101
+ "-s",
102
+ "--verbose",
103
+ "-v",
104
+ ],
105
+ tasks: ["--help", "-h", "--json", "--silent", "-s", "--verbose", "-v"],
106
+ base: ["--help", "-h", "--repo", "--silent", "-s", "--verbose", "-v"],
107
+ template: [
108
+ "--help",
109
+ "-h",
110
+ "--template",
111
+ "-t",
112
+ "--silent",
113
+ "-s",
114
+ "--verbose",
115
+ "-v",
116
+ ],
117
+ work: [
118
+ "--help",
119
+ "-h",
120
+ "--opencode",
121
+ "--claude",
122
+ "--silent",
123
+ "-s",
124
+ "--verbose",
125
+ "-v",
126
+ ],
127
+ loop: [
128
+ "--help",
129
+ "-h",
130
+ "--min-loops",
131
+ "--max-loops",
132
+ "--opencode",
133
+ "--claude",
134
+ "--silent",
135
+ "-s",
136
+ "--verbose",
137
+ "-v",
138
+ ],
139
+ status: ["--help", "-h", "--json", "--silent", "-s", "--verbose", "-v"],
140
+ clean: [
141
+ "--help",
142
+ "-h",
143
+ "--dry-run",
144
+ "--merged-into",
145
+ "--silent",
146
+ "-s",
147
+ "--verbose",
148
+ "-v",
149
+ ],
150
+ completions: ["--help", "-h"],
151
+ }
152
+
153
+ const words = (values: readonly string[]) => values.join(" ")
154
+
155
+ const bashCase = (command: string, subcommands?: readonly string[]) => {
156
+ const options = commandOptions[command] ?? [
157
+ "--help",
158
+ "-h",
159
+ "--silent",
160
+ "-s",
161
+ "--verbose",
162
+ "-v",
163
+ ]
164
+ const completions = subcommands ? [...subcommands, ...options] : options
165
+ return `\t\t${command})\n\t\t\tCOMPREPLY=( $(compgen -W "${words(completions)}" -- "$cur") )\n\t\t\treturn 0\n\t\t\t;;`
166
+ }
167
+
168
+ const zshCommandSpec = (command: string, description: string) =>
169
+ ` '${command}:${description}'`
170
+
171
+ const generateBashCompletions = () => `# bash completion for agency
172
+ _agency_completions() {
173
+ local cur prev command
174
+ COMPREPLY=()
175
+ cur="\${COMP_WORDS[COMP_CWORD]}"
176
+ prev="\${COMP_WORDS[COMP_CWORD-1]}"
177
+
178
+ if [[ $COMP_CWORD -eq 1 ]]; then
179
+ COMPREPLY=( $(compgen -W "${words([...commands, ...globalOptions])}" -- "$cur") )
180
+ return 0
181
+ fi
182
+
183
+ command="\${COMP_WORDS[1]}"
184
+ case "$command" in
185
+ ${bashCase("template", templateSubcommands)}
186
+ ${bashCase("base", baseSubcommands)}
187
+ ${bashCase("completions", completionSubcommands)}
188
+ ${commands
189
+ .filter((command) => !["template", "base", "completions"].includes(command))
190
+ .map((command) => bashCase(command))
191
+ .join("\n")}
192
+ esac
193
+
194
+ case "$prev" in
195
+ --template|-t|--emit|--branch|--task|--from|--remote|-r|--merged-into)
196
+ return 0
197
+ ;;
198
+ esac
199
+ }
200
+
201
+ complete -F _agency_completions agency
202
+ `
203
+
204
+ const generateZshCompletions = () => `#compdef agency
205
+ # zsh completion for agency
206
+
207
+ _agency() {
208
+ local context state line
209
+ local -a agency_commands
210
+ typeset -A opt_args
211
+
212
+ agency_commands=(
213
+ ${zshCommandSpec("init", "Initialize agency with template selection")}
214
+ ${zshCommandSpec("task", "Initialize template files on a feature branch")}
215
+ ${zshCommandSpec("tasks", "List all task branches")}
216
+ ${zshCommandSpec("edit", "Open TASK.md in system editor")}
217
+ ${zshCommandSpec("work", "Start working on TASK.md with OpenCode")}
218
+ ${zshCommandSpec("template", "Template management commands")}
219
+ ${zshCommandSpec("emit", "Emit a branch with backpack files reverted")}
220
+ ${zshCommandSpec("emitted", "Get the name of the emitted branch")}
221
+ ${zshCommandSpec("pr", "Run gh pr with the emitted branch name")}
222
+ ${zshCommandSpec("push", "Emit, push to remote, return to source")}
223
+ ${zshCommandSpec("pull", "Pull commits from remote emit branch to source")}
224
+ ${zshCommandSpec("rebase", "Rebase source branch onto base branch")}
225
+ ${zshCommandSpec("base", "Get or set the base branch")}
226
+ ${zshCommandSpec("switch", "Toggle between source and emitted branch")}
227
+ ${zshCommandSpec("source", "Switch to source branch from emitted branch")}
228
+ ${zshCommandSpec("merge", "Merge emitted branch into base branch")}
229
+ ${zshCommandSpec("status", "Show agency status for this repository")}
230
+ ${zshCommandSpec("clean", "Delete branches merged into a specified branch")}
231
+ ${zshCommandSpec("loop", "Run a Ralph Wiggum loop to complete all tasks")}
232
+ ${zshCommandSpec("completions", "Generate shell completion scripts")}
233
+ )
234
+
235
+ _arguments -C \\
236
+ '(-h --help)'{-h,--help}'[show help]' \\
237
+ '(-v --version)'{-v,--version}'[show version]' \\
238
+ '--no-color[disable color output]' \\
239
+ '(-s --silent)'{-s,--silent}'[suppress output messages]' \\
240
+ '--verbose[show verbose output]' \\
241
+ '1:command:->command' \\
242
+ '*::arg:->args'
243
+
244
+ case $state in
245
+ command)
246
+ _describe 'agency command' agency_commands
247
+ ;;
248
+ args)
249
+ case $line[1] in
250
+ template)
251
+ _arguments \\
252
+ '(-h --help)'{-h,--help}'[show help]' \\
253
+ '(-t --template)'{-t,--template}'[template name]:template:' \\
254
+ '(-s --silent)'{-s,--silent}'[suppress output messages]' \\
255
+ '--verbose[show verbose output]' \\
256
+ '1:subcommand:((use\\:Set\\ template save\\:Save\\ files list\\:List\\ files view\\:View\\ file delete\\:Delete\\ files))' \\
257
+ '*:file:_files'
258
+ ;;
259
+ base)
260
+ _arguments \\
261
+ '(-h --help)'{-h,--help}'[show help]' \\
262
+ '--repo[use repository-local configuration]' \\
263
+ '(-s --silent)'{-s,--silent}'[suppress output messages]' \\
264
+ '--verbose[show verbose output]' \\
265
+ '1:subcommand:((get\\:Get\\ base\\ branch set\\:Set\\ base\\ branch))' \\
266
+ '*:branch:'
267
+ ;;
268
+ completions)
269
+ _arguments \\
270
+ '(-h --help)'{-h,--help}'[show help]' \\
271
+ '1:shell:((bash\\:Generate\\ bash\\ completions zsh\\:Generate\\ zsh\\ completions))'
272
+ ;;
273
+ *)
274
+ _arguments \\
275
+ '(-h --help)'{-h,--help}'[show help]' \\
276
+ '(-s --silent)'{-s,--silent}'[suppress output messages]' \\
277
+ '--verbose[show verbose output]'
278
+ ;;
279
+ esac
280
+ ;;
281
+ esac
282
+ }
283
+
284
+ _agency "$@"
285
+ `
286
+
287
+ export const completions = (shell: string | undefined) =>
288
+ Effect.gen(function* () {
289
+ if (!shell || !shells.includes(shell as Shell)) {
290
+ return yield* Effect.fail(
291
+ new Error("Usage: agency completions <bash|zsh>"),
292
+ )
293
+ }
294
+
295
+ console.log(
296
+ shell === "bash" ? generateBashCompletions() : generateZshCompletions(),
297
+ )
298
+ })
299
+
300
+ export const help = `
301
+ Usage: agency completions <bash|zsh>
302
+
303
+ Generate shell completion scripts.
304
+
305
+ Examples:
306
+ agency completions zsh > ~/.zsh/completions/_agency
307
+ agency completions bash > ~/.local/share/bash-completion/completions/agency
308
+ `
@@ -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
  }