@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 +7 -2
- package/package.json +1 -1
- package/src/commands/push.test.ts +123 -0
- package/src/commands/push.ts +91 -43
- package/src/services/GitService.ts +4 -0
package/cli.ts
CHANGED
|
@@ -178,7 +178,7 @@ const commands: Record<string, Command> = {
|
|
|
178
178
|
},
|
|
179
179
|
push: {
|
|
180
180
|
name: "push",
|
|
181
|
-
description: "
|
|
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 [
|
|
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
|
@@ -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
|
}
|