@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 +25 -0
- package/cli.ts +24 -2
- package/package.json +1 -1
- package/src/commands/completions.test.ts +51 -0
- package/src/commands/completions.ts +308 -0
- package/src/commands/push.test.ts +123 -0
- package/src/commands/push.ts +91 -43
- package/src/services/GitService.ts +4 -0
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: "
|
|
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 [
|
|
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
|
@@ -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")
|
package/src/commands/push.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
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:
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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 [
|
|
438
|
+
Usage: agency push [arguments] [options]
|
|
390
439
|
|
|
391
|
-
Create
|
|
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
|
|
408
|
-
-
|
|
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
|
-
|
|
412
|
-
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
-
|
|
430
|
-
-
|
|
431
|
-
-
|
|
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
|
}
|