@markjaquith/agency 0.5.0
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/LICENSE +21 -0
- package/README.md +109 -0
- package/cli.ts +569 -0
- package/index.ts +1 -0
- package/package.json +65 -0
- package/src/commands/base.test.ts +198 -0
- package/src/commands/base.ts +198 -0
- package/src/commands/clean.test.ts +299 -0
- package/src/commands/clean.ts +320 -0
- package/src/commands/emit.test.ts +412 -0
- package/src/commands/emit.ts +521 -0
- package/src/commands/emitted.test.ts +226 -0
- package/src/commands/emitted.ts +57 -0
- package/src/commands/init.test.ts +311 -0
- package/src/commands/init.ts +140 -0
- package/src/commands/merge.test.ts +365 -0
- package/src/commands/merge.ts +253 -0
- package/src/commands/pull.test.ts +385 -0
- package/src/commands/pull.ts +205 -0
- package/src/commands/push.test.ts +394 -0
- package/src/commands/push.ts +346 -0
- package/src/commands/save.test.ts +247 -0
- package/src/commands/save.ts +162 -0
- package/src/commands/source.test.ts +195 -0
- package/src/commands/source.ts +72 -0
- package/src/commands/status.test.ts +489 -0
- package/src/commands/status.ts +258 -0
- package/src/commands/switch.test.ts +194 -0
- package/src/commands/switch.ts +84 -0
- package/src/commands/task-branching.test.ts +334 -0
- package/src/commands/task-edit.test.ts +141 -0
- package/src/commands/task-main.test.ts +872 -0
- package/src/commands/task.ts +712 -0
- package/src/commands/tasks.test.ts +335 -0
- package/src/commands/tasks.ts +155 -0
- package/src/commands/template-delete.test.ts +178 -0
- package/src/commands/template-delete.ts +98 -0
- package/src/commands/template-list.test.ts +135 -0
- package/src/commands/template-list.ts +87 -0
- package/src/commands/template-view.test.ts +158 -0
- package/src/commands/template-view.ts +86 -0
- package/src/commands/template.test.ts +32 -0
- package/src/commands/template.ts +96 -0
- package/src/commands/use.test.ts +87 -0
- package/src/commands/use.ts +97 -0
- package/src/commands/work.test.ts +462 -0
- package/src/commands/work.ts +193 -0
- package/src/errors.ts +17 -0
- package/src/schemas.ts +33 -0
- package/src/services/AgencyMetadataService.ts +287 -0
- package/src/services/ClaudeService.test.ts +184 -0
- package/src/services/ClaudeService.ts +91 -0
- package/src/services/ConfigService.ts +115 -0
- package/src/services/FileSystemService.ts +222 -0
- package/src/services/GitService.ts +751 -0
- package/src/services/OpencodeService.ts +263 -0
- package/src/services/PromptService.ts +183 -0
- package/src/services/TemplateService.ts +75 -0
- package/src/test-utils.ts +362 -0
- package/src/types/native-exec.d.ts +8 -0
- package/src/types.ts +216 -0
- package/src/utils/colors.ts +178 -0
- package/src/utils/command.ts +17 -0
- package/src/utils/effect.ts +281 -0
- package/src/utils/exec.ts +48 -0
- package/src/utils/paths.ts +51 -0
- package/src/utils/pr-branch.test.ts +372 -0
- package/src/utils/pr-branch.ts +473 -0
- package/src/utils/process.ts +110 -0
- package/src/utils/spinner.ts +82 -0
- package/templates/AGENCY.md +20 -0
- package/templates/AGENTS.md +11 -0
- package/templates/CLAUDE.md +3 -0
- package/templates/TASK.md +5 -0
- package/templates/opencode.json +4 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { Effect } from "effect"
|
|
2
|
+
import type { BaseCommandOptions } from "../utils/command"
|
|
3
|
+
import { GitService } from "../services/GitService"
|
|
4
|
+
import { ConfigService } from "../services/ConfigService"
|
|
5
|
+
import { resolveBranchPairWithAgencyJson } from "../utils/pr-branch"
|
|
6
|
+
import highlight, { done } from "../utils/colors"
|
|
7
|
+
import {
|
|
8
|
+
createLoggers,
|
|
9
|
+
ensureGitRepo,
|
|
10
|
+
withBranchProtection,
|
|
11
|
+
} from "../utils/effect"
|
|
12
|
+
import { withSpinner } from "../utils/spinner"
|
|
13
|
+
|
|
14
|
+
interface PullOptions extends BaseCommandOptions {
|
|
15
|
+
remote?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const pull = (options: PullOptions = {}) =>
|
|
19
|
+
Effect.gen(function* () {
|
|
20
|
+
const gitRoot = yield* ensureGitRepo()
|
|
21
|
+
|
|
22
|
+
// Wrap the entire pull operation with branch protection
|
|
23
|
+
// This ensures we return to the original branch on Ctrl-C interrupt
|
|
24
|
+
yield* withBranchProtection(gitRoot, pullCore(gitRoot, options))
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
const pullCore = (gitRoot: string, options: PullOptions) =>
|
|
28
|
+
Effect.gen(function* () {
|
|
29
|
+
const { verbose = false } = options
|
|
30
|
+
const { log, verboseLog } = createLoggers(options)
|
|
31
|
+
|
|
32
|
+
const git = yield* GitService
|
|
33
|
+
const configService = yield* ConfigService
|
|
34
|
+
|
|
35
|
+
// Resolve remote name (use provided option, config, or auto-detect)
|
|
36
|
+
const remote = options.remote
|
|
37
|
+
? yield* git.resolveRemote(gitRoot, options.remote)
|
|
38
|
+
: yield* git.resolveRemote(gitRoot)
|
|
39
|
+
|
|
40
|
+
// Load config
|
|
41
|
+
const config = yield* configService.loadConfig()
|
|
42
|
+
|
|
43
|
+
// Get current branch
|
|
44
|
+
let currentBranch = yield* git.getCurrentBranch(gitRoot)
|
|
45
|
+
|
|
46
|
+
// Resolve branch pair to find source and emit branches
|
|
47
|
+
const branches = yield* resolveBranchPairWithAgencyJson(
|
|
48
|
+
gitRoot,
|
|
49
|
+
currentBranch,
|
|
50
|
+
config.sourceBranchPattern,
|
|
51
|
+
config.emitBranch,
|
|
52
|
+
)
|
|
53
|
+
const { sourceBranch, emitBranch, isOnEmitBranch } = branches
|
|
54
|
+
|
|
55
|
+
// If we're on emit branch, switch to source branch
|
|
56
|
+
if (isOnEmitBranch) {
|
|
57
|
+
verboseLog(
|
|
58
|
+
`Currently on emit branch ${highlight.branch(currentBranch)}, switching to source branch ${highlight.branch(sourceBranch)}`,
|
|
59
|
+
)
|
|
60
|
+
const sourceExists = yield* git.branchExists(gitRoot, sourceBranch)
|
|
61
|
+
if (!sourceExists) {
|
|
62
|
+
return yield* Effect.fail(
|
|
63
|
+
new Error(
|
|
64
|
+
`Source branch ${highlight.branch(sourceBranch)} does not exist`,
|
|
65
|
+
),
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
yield* git.checkoutBranch(gitRoot, sourceBranch)
|
|
69
|
+
currentBranch = sourceBranch
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
verboseLog(`Source branch: ${highlight.branch(sourceBranch)}`)
|
|
73
|
+
verboseLog(`Emit branch: ${highlight.branch(emitBranch)}`)
|
|
74
|
+
|
|
75
|
+
// Fetch the remote emit branch
|
|
76
|
+
const remoteEmitBranch = `${remote}/${emitBranch}`
|
|
77
|
+
const fetchOperation = Effect.gen(function* () {
|
|
78
|
+
const result = yield* git.fetch(gitRoot, remote, emitBranch).pipe(
|
|
79
|
+
Effect.map(() => true),
|
|
80
|
+
Effect.catchAll(() => Effect.succeed(false)),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
if (!result) {
|
|
84
|
+
return yield* Effect.fail(
|
|
85
|
+
new Error(
|
|
86
|
+
`Failed to fetch ${highlight.branch(remoteEmitBranch)}. Does the remote branch exist?`,
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
verboseLog(`Fetched ${highlight.branch(remoteEmitBranch)}`)
|
|
92
|
+
|
|
93
|
+
// Check if remote emit branch exists after fetch
|
|
94
|
+
const remoteExists = yield* git.branchExists(gitRoot, remoteEmitBranch)
|
|
95
|
+
if (!remoteExists) {
|
|
96
|
+
return yield* Effect.fail(
|
|
97
|
+
new Error(
|
|
98
|
+
`Remote emit branch ${highlight.branch(remoteEmitBranch)} does not exist`,
|
|
99
|
+
),
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
yield* withSpinner(fetchOperation, {
|
|
105
|
+
text: `Fetching ${highlight.branch(remoteEmitBranch)}`,
|
|
106
|
+
successText: `Fetched ${highlight.branch(remoteEmitBranch)}`,
|
|
107
|
+
enabled: !options.silent && !verbose,
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
// Check if local emit branch exists
|
|
111
|
+
const localEmitExists = yield* git.branchExists(gitRoot, emitBranch)
|
|
112
|
+
if (!localEmitExists) {
|
|
113
|
+
verboseLog(
|
|
114
|
+
`Local emit branch ${highlight.branch(emitBranch)} does not exist, comparing against source branch`,
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Get the list of commits on remote emit branch that aren't on local emit branch
|
|
119
|
+
// If local emit branch doesn't exist, compare against source branch
|
|
120
|
+
const compareBase = localEmitExists ? emitBranch : sourceBranch
|
|
121
|
+
verboseLog(
|
|
122
|
+
`Comparing ${highlight.branch(remoteEmitBranch)} against ${highlight.branch(compareBase)}`,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
const commitsResult = yield* git
|
|
126
|
+
.getCommitsBetween(gitRoot, compareBase, remoteEmitBranch)
|
|
127
|
+
.pipe(Effect.catchAll(() => Effect.succeed("")))
|
|
128
|
+
|
|
129
|
+
if (!commitsResult || commitsResult.trim() === "") {
|
|
130
|
+
log(done("No new commits to pull"))
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const commits = commitsResult.split("\n").filter((c) => c.trim().length > 0)
|
|
135
|
+
verboseLog(`Found ${commits.length} commits to cherry-pick`)
|
|
136
|
+
|
|
137
|
+
// Cherry-pick each commit
|
|
138
|
+
let successCount = 0
|
|
139
|
+
let failedCommit: string | null = null
|
|
140
|
+
|
|
141
|
+
for (const commit of commits) {
|
|
142
|
+
verboseLog(`Cherry-picking ${highlight.commit(commit.substring(0, 8))}`)
|
|
143
|
+
|
|
144
|
+
const result = yield* git.cherryPick(gitRoot, commit).pipe(
|
|
145
|
+
Effect.map(() => true),
|
|
146
|
+
Effect.catchAll(() => {
|
|
147
|
+
failedCommit = commit
|
|
148
|
+
return Effect.succeed(false)
|
|
149
|
+
}),
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
if (!result) {
|
|
153
|
+
break
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
successCount++
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (failedCommit) {
|
|
160
|
+
log(
|
|
161
|
+
`Cherry-picked ${successCount} of ${commits.length} commits before conflict`,
|
|
162
|
+
)
|
|
163
|
+
return yield* Effect.fail(
|
|
164
|
+
new Error(
|
|
165
|
+
`Cherry-pick conflict at commit ${highlight.commit(failedCommit.substring(0, 8))}. Resolve conflicts and continue with: git cherry-pick --continue`,
|
|
166
|
+
),
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
log(
|
|
171
|
+
done(
|
|
172
|
+
`Pulled ${commits.length} commit${commits.length === 1 ? "" : "s"} from ${highlight.branch(remoteEmitBranch)}`,
|
|
173
|
+
),
|
|
174
|
+
)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
export const help = `
|
|
178
|
+
Usage: agency pull [options]
|
|
179
|
+
|
|
180
|
+
Pull commits from the remote emit branch and cherry-pick them onto the source branch.
|
|
181
|
+
|
|
182
|
+
This command is useful when someone else has pushed commits to the emit branch
|
|
183
|
+
(e.g., after a PR review) and you want to bring those changes back into your
|
|
184
|
+
source branch.
|
|
185
|
+
|
|
186
|
+
Workflow:
|
|
187
|
+
1. Determines the source and emit branch names
|
|
188
|
+
2. If on emit branch, switches to source branch
|
|
189
|
+
3. Fetches the remote emit branch
|
|
190
|
+
4. Finds commits on remote emit branch that aren't on source branch
|
|
191
|
+
5. Cherry-picks each commit onto the source branch
|
|
192
|
+
|
|
193
|
+
Options:
|
|
194
|
+
-r, --remote Remote name to fetch from (defaults to 'origin')
|
|
195
|
+
|
|
196
|
+
Examples:
|
|
197
|
+
agency pull # Pull from origin/<emit-branch>
|
|
198
|
+
agency pull --remote upstream # Pull from upstream/<emit-branch>
|
|
199
|
+
|
|
200
|
+
Notes:
|
|
201
|
+
- If a cherry-pick conflict occurs, you'll need to resolve it manually
|
|
202
|
+
- Use 'git cherry-pick --continue' after resolving conflicts
|
|
203
|
+
- The command will stop at the first conflict
|
|
204
|
+
- Only commits that don't exist on the source branch are cherry-picked
|
|
205
|
+
`
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import { test, expect, describe, beforeEach, afterEach } from "bun:test"
|
|
2
|
+
import { join } from "path"
|
|
3
|
+
import { push } from "./push"
|
|
4
|
+
import {
|
|
5
|
+
createTempDir,
|
|
6
|
+
cleanupTempDir,
|
|
7
|
+
initGitRepo,
|
|
8
|
+
getCurrentBranch,
|
|
9
|
+
getGitOutput,
|
|
10
|
+
createCommit,
|
|
11
|
+
checkoutBranch,
|
|
12
|
+
createBranch,
|
|
13
|
+
addAndCommit,
|
|
14
|
+
renameBranch,
|
|
15
|
+
runTestEffect,
|
|
16
|
+
} from "../test-utils"
|
|
17
|
+
|
|
18
|
+
async function setupAgencyJson(gitRoot: string): Promise<void> {
|
|
19
|
+
const agencyJson = {
|
|
20
|
+
version: 1,
|
|
21
|
+
injectedFiles: ["AGENTS.MD", "TASK.md"],
|
|
22
|
+
template: "test",
|
|
23
|
+
createdAt: new Date().toISOString(),
|
|
24
|
+
}
|
|
25
|
+
await Bun.write(
|
|
26
|
+
join(gitRoot, "agency.json"),
|
|
27
|
+
JSON.stringify(agencyJson, null, 2) + "\n",
|
|
28
|
+
)
|
|
29
|
+
await addAndCommit(gitRoot, "agency.json", "Add agency.json")
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function setupBareRemote(tempDir: string): Promise<string> {
|
|
33
|
+
// Create a bare repository to use as remote
|
|
34
|
+
const remoteDir = join(tempDir, "remote.git")
|
|
35
|
+
await Bun.spawn(["git", "init", "--bare", remoteDir], {
|
|
36
|
+
stdout: "pipe",
|
|
37
|
+
stderr: "pipe",
|
|
38
|
+
}).exited
|
|
39
|
+
return remoteDir
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("push command", () => {
|
|
43
|
+
let tempDir: string
|
|
44
|
+
let remoteDir: string
|
|
45
|
+
let originalCwd: string
|
|
46
|
+
|
|
47
|
+
beforeEach(async () => {
|
|
48
|
+
tempDir = await createTempDir()
|
|
49
|
+
originalCwd = process.cwd()
|
|
50
|
+
process.chdir(tempDir)
|
|
51
|
+
|
|
52
|
+
// Set config path to non-existent file to use defaults
|
|
53
|
+
process.env.AGENCY_CONFIG_PATH = join(tempDir, "non-existent-config.json")
|
|
54
|
+
|
|
55
|
+
// Initialize git repo
|
|
56
|
+
await initGitRepo(tempDir)
|
|
57
|
+
await createCommit(tempDir, "Initial commit")
|
|
58
|
+
|
|
59
|
+
// Rename to main if needed
|
|
60
|
+
const currentBranch = await getCurrentBranch(tempDir)
|
|
61
|
+
if (currentBranch === "master") {
|
|
62
|
+
await renameBranch(tempDir, "main")
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Setup bare remote and push main
|
|
66
|
+
remoteDir = await setupBareRemote(tempDir)
|
|
67
|
+
await Bun.spawn(["git", "remote", "add", "origin", remoteDir], {
|
|
68
|
+
cwd: tempDir,
|
|
69
|
+
stdout: "pipe",
|
|
70
|
+
stderr: "pipe",
|
|
71
|
+
}).exited
|
|
72
|
+
await Bun.spawn(["git", "push", "-u", "origin", "main"], {
|
|
73
|
+
cwd: tempDir,
|
|
74
|
+
stdout: "pipe",
|
|
75
|
+
stderr: "pipe",
|
|
76
|
+
}).exited
|
|
77
|
+
|
|
78
|
+
// Setup agency.json
|
|
79
|
+
await setupAgencyJson(tempDir)
|
|
80
|
+
|
|
81
|
+
// Create a source branch (with agency/ prefix per new default config)
|
|
82
|
+
await createBranch(tempDir, "agency/feature")
|
|
83
|
+
await createCommit(tempDir, "Feature work")
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
afterEach(async () => {
|
|
87
|
+
process.chdir(originalCwd)
|
|
88
|
+
delete process.env.AGENCY_CONFIG_PATH
|
|
89
|
+
await cleanupTempDir(tempDir)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
describe("basic functionality", () => {
|
|
93
|
+
test("creates emit branch, pushes it, and returns to source", async () => {
|
|
94
|
+
// We're on agency/feature branch (source)
|
|
95
|
+
expect(await getCurrentBranch(tempDir)).toBe("agency/feature")
|
|
96
|
+
|
|
97
|
+
// Run push command
|
|
98
|
+
await runTestEffect(
|
|
99
|
+
push({ baseBranch: "main", silent: true, skipFilter: true }),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
// Should be back on agency/feature branch (source)
|
|
103
|
+
expect(await getCurrentBranch(tempDir)).toBe("agency/feature")
|
|
104
|
+
|
|
105
|
+
// emit branch (feature) should exist locally and on remote
|
|
106
|
+
const branches = await getGitOutput(tempDir, ["branch"])
|
|
107
|
+
expect(branches).toContain("feature")
|
|
108
|
+
|
|
109
|
+
const remoteBranches = await getGitOutput(tempDir, [
|
|
110
|
+
"ls-remote",
|
|
111
|
+
"--heads",
|
|
112
|
+
"origin",
|
|
113
|
+
"feature",
|
|
114
|
+
])
|
|
115
|
+
expect(remoteBranches).toContain("feature")
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test("works with custom branch name", async () => {
|
|
119
|
+
await runTestEffect(
|
|
120
|
+
push({
|
|
121
|
+
baseBranch: "main",
|
|
122
|
+
branch: "custom-pr-branch",
|
|
123
|
+
silent: true,
|
|
124
|
+
skipFilter: true,
|
|
125
|
+
}),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
// Should be back on agency/feature branch (source)
|
|
129
|
+
expect(await getCurrentBranch(tempDir)).toBe("agency/feature")
|
|
130
|
+
|
|
131
|
+
// Custom branch should exist
|
|
132
|
+
const branches = await getGitOutput(tempDir, ["branch"])
|
|
133
|
+
expect(branches).toContain("custom-pr-branch")
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test("recreates emit branch if it already exists", async () => {
|
|
137
|
+
// First push
|
|
138
|
+
await runTestEffect(
|
|
139
|
+
push({ baseBranch: "main", silent: true, skipFilter: true }),
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
// Make more changes on source branch
|
|
143
|
+
await checkoutBranch(tempDir, "agency/feature")
|
|
144
|
+
await createCommit(tempDir, "More feature work")
|
|
145
|
+
|
|
146
|
+
// Second push should recreate the emit branch
|
|
147
|
+
await runTestEffect(
|
|
148
|
+
push({ baseBranch: "main", silent: true, skipFilter: true }),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
// Should still be back on agency/feature branch (source)
|
|
152
|
+
expect(await getCurrentBranch(tempDir)).toBe("agency/feature")
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
describe("error handling", () => {
|
|
157
|
+
test("switches to source branch when run from emit branch", async () => {
|
|
158
|
+
// First create the emit branch from source branch
|
|
159
|
+
await runTestEffect(
|
|
160
|
+
push({ baseBranch: "main", silent: true, skipFilter: true }),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
// Now we're on agency/feature, switch to the emit branch (feature)
|
|
164
|
+
await checkoutBranch(tempDir, "feature")
|
|
165
|
+
|
|
166
|
+
// Verify we're on the emit branch
|
|
167
|
+
expect(await getCurrentBranch(tempDir)).toBe("feature")
|
|
168
|
+
|
|
169
|
+
// Make a change on source branch that we'll push
|
|
170
|
+
await checkoutBranch(tempDir, "agency/feature")
|
|
171
|
+
await createCommit(tempDir, "Another feature commit")
|
|
172
|
+
|
|
173
|
+
// Switch back to emit branch
|
|
174
|
+
await checkoutBranch(tempDir, "feature")
|
|
175
|
+
|
|
176
|
+
// Run push from emit branch - should detect we're on emit branch,
|
|
177
|
+
// switch to source (agency/feature), and continue
|
|
178
|
+
await runTestEffect(
|
|
179
|
+
push({ baseBranch: "main", silent: true, skipFilter: true }),
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
// Should be back on agency/feature branch (the source branch)
|
|
183
|
+
expect(await getCurrentBranch(tempDir)).toBe("agency/feature")
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
test("throws error when not in a git repository", async () => {
|
|
187
|
+
const nonGitDir = await createTempDir()
|
|
188
|
+
process.chdir(nonGitDir)
|
|
189
|
+
|
|
190
|
+
await expect(
|
|
191
|
+
runTestEffect(
|
|
192
|
+
push({ baseBranch: "main", silent: true, skipFilter: true }),
|
|
193
|
+
),
|
|
194
|
+
).rejects.toThrow("Not in a git repository")
|
|
195
|
+
|
|
196
|
+
await cleanupTempDir(nonGitDir)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
test("handles push failure gracefully", async () => {
|
|
200
|
+
// Remove the remote to cause push to fail
|
|
201
|
+
await Bun.spawn(["git", "remote", "remove", "origin"], {
|
|
202
|
+
cwd: tempDir,
|
|
203
|
+
stdout: "pipe",
|
|
204
|
+
stderr: "pipe",
|
|
205
|
+
}).exited
|
|
206
|
+
|
|
207
|
+
// Push should fail because no remote exists
|
|
208
|
+
await expect(
|
|
209
|
+
runTestEffect(
|
|
210
|
+
push({ baseBranch: "main", silent: true, skipFilter: true }),
|
|
211
|
+
),
|
|
212
|
+
).rejects.toThrow(/No git remotes found/)
|
|
213
|
+
})
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
describe("silent mode", () => {
|
|
217
|
+
test("silent flag suppresses output", async () => {
|
|
218
|
+
// Capture output
|
|
219
|
+
const originalLog = console.log
|
|
220
|
+
let logCalled = false
|
|
221
|
+
console.log = () => {
|
|
222
|
+
logCalled = true
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
await runTestEffect(
|
|
226
|
+
push({ baseBranch: "main", silent: true, skipFilter: true }),
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
console.log = originalLog
|
|
230
|
+
expect(logCalled).toBe(false)
|
|
231
|
+
})
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
describe("force push", () => {
|
|
235
|
+
test("force pushes when branch has diverged and --force is provided", async () => {
|
|
236
|
+
// First push
|
|
237
|
+
await runTestEffect(
|
|
238
|
+
push({ baseBranch: "main", silent: true, skipFilter: true }),
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
// Make changes on source branch
|
|
242
|
+
await checkoutBranch(tempDir, "agency/feature")
|
|
243
|
+
await createCommit(tempDir, "More feature work")
|
|
244
|
+
|
|
245
|
+
// Modify the emit branch to create divergence
|
|
246
|
+
await checkoutBranch(tempDir, "feature")
|
|
247
|
+
await createCommit(tempDir, "Direct emit branch commit")
|
|
248
|
+
await Bun.spawn(["git", "push"], {
|
|
249
|
+
cwd: tempDir,
|
|
250
|
+
stdout: "pipe",
|
|
251
|
+
stderr: "pipe",
|
|
252
|
+
}).exited
|
|
253
|
+
|
|
254
|
+
// Go back to source branch and try to push again with --force
|
|
255
|
+
await checkoutBranch(tempDir, "agency/feature")
|
|
256
|
+
|
|
257
|
+
// Capture output to check for force push message
|
|
258
|
+
const originalLog = console.log
|
|
259
|
+
let logMessages: string[] = []
|
|
260
|
+
console.log = (msg: string) => {
|
|
261
|
+
logMessages.push(msg)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
await runTestEffect(
|
|
265
|
+
push({
|
|
266
|
+
baseBranch: "main",
|
|
267
|
+
force: true,
|
|
268
|
+
silent: false,
|
|
269
|
+
skipFilter: true,
|
|
270
|
+
}),
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
console.log = originalLog
|
|
274
|
+
|
|
275
|
+
// Should be back on agency/feature branch (source)
|
|
276
|
+
expect(await getCurrentBranch(tempDir)).toBe("agency/feature")
|
|
277
|
+
|
|
278
|
+
// Should have reported force push
|
|
279
|
+
expect(logMessages.some((msg) => msg.includes("(forced)"))).toBe(true)
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
test("suggests using --force when push is rejected without it", async () => {
|
|
283
|
+
// First push
|
|
284
|
+
await runTestEffect(
|
|
285
|
+
push({ baseBranch: "main", silent: true, skipFilter: true }),
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
// Make changes on source branch
|
|
289
|
+
await checkoutBranch(tempDir, "agency/feature")
|
|
290
|
+
await createCommit(tempDir, "More feature work")
|
|
291
|
+
|
|
292
|
+
// Modify the emit branch to create divergence
|
|
293
|
+
await checkoutBranch(tempDir, "feature")
|
|
294
|
+
await createCommit(tempDir, "Direct emit branch commit")
|
|
295
|
+
await Bun.spawn(["git", "push"], {
|
|
296
|
+
cwd: tempDir,
|
|
297
|
+
stdout: "pipe",
|
|
298
|
+
stderr: "pipe",
|
|
299
|
+
}).exited
|
|
300
|
+
|
|
301
|
+
// Go back to source branch and try to push without --force
|
|
302
|
+
await checkoutBranch(tempDir, "agency/feature")
|
|
303
|
+
|
|
304
|
+
// Should throw error suggesting --force
|
|
305
|
+
await expect(
|
|
306
|
+
runTestEffect(
|
|
307
|
+
push({ baseBranch: "main", silent: true, skipFilter: true }),
|
|
308
|
+
),
|
|
309
|
+
).rejects.toThrow(/agency push --force/)
|
|
310
|
+
|
|
311
|
+
// Should still be on agency/feature branch (not left in intermediate state)
|
|
312
|
+
expect(await getCurrentBranch(tempDir)).toBe("agency/feature")
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
test("does not report force push when --force is provided but not needed", async () => {
|
|
316
|
+
// Capture output to check for force push message
|
|
317
|
+
const originalLog = console.log
|
|
318
|
+
let logMessages: string[] = []
|
|
319
|
+
console.log = (msg: string) => {
|
|
320
|
+
logMessages.push(msg)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// First push with --force (but it won't actually need force)
|
|
324
|
+
await runTestEffect(
|
|
325
|
+
push({
|
|
326
|
+
baseBranch: "main",
|
|
327
|
+
force: true,
|
|
328
|
+
silent: false,
|
|
329
|
+
skipFilter: true,
|
|
330
|
+
}),
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
console.log = originalLog
|
|
334
|
+
|
|
335
|
+
// Should be back on agency/feature branch (source)
|
|
336
|
+
expect(await getCurrentBranch(tempDir)).toBe("agency/feature")
|
|
337
|
+
|
|
338
|
+
// Should NOT have reported force push (since it wasn't actually used)
|
|
339
|
+
expect(logMessages.some((msg) => msg.includes("Force pushed"))).toBe(
|
|
340
|
+
false,
|
|
341
|
+
)
|
|
342
|
+
expect(logMessages.some((msg) => msg.includes("Pushed"))).toBe(true)
|
|
343
|
+
})
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
describe("--pr flag", () => {
|
|
347
|
+
test("handles gh CLI failure gracefully and continues", async () => {
|
|
348
|
+
// Capture error output
|
|
349
|
+
const originalError = console.error
|
|
350
|
+
let errorMessages: string[] = []
|
|
351
|
+
console.error = (msg: string) => {
|
|
352
|
+
errorMessages.push(msg)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Should not throw - command should complete despite gh failure
|
|
356
|
+
// (gh will fail in test environment because there's no GitHub remote)
|
|
357
|
+
await runTestEffect(
|
|
358
|
+
push({ baseBranch: "main", pr: true, silent: true, skipFilter: true }),
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
console.error = originalError
|
|
362
|
+
|
|
363
|
+
// Should be back on agency/feature branch (command completes despite gh failure)
|
|
364
|
+
expect(await getCurrentBranch(tempDir)).toBe("agency/feature")
|
|
365
|
+
|
|
366
|
+
// Should have warned about gh failure
|
|
367
|
+
expect(
|
|
368
|
+
errorMessages.some((msg) => msg.includes("Failed to open GitHub PR")),
|
|
369
|
+
).toBe(true)
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
test("does not call gh when --pr flag is not set", async () => {
|
|
373
|
+
// Capture error output
|
|
374
|
+
const originalError = console.error
|
|
375
|
+
let errorMessages: string[] = []
|
|
376
|
+
console.error = (msg: string) => {
|
|
377
|
+
errorMessages.push(msg)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Push without --pr flag
|
|
381
|
+
await runTestEffect(
|
|
382
|
+
push({ baseBranch: "main", silent: true, skipFilter: true }),
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
console.error = originalError
|
|
386
|
+
|
|
387
|
+
// Should be back on agency/feature branch (source)
|
|
388
|
+
expect(await getCurrentBranch(tempDir)).toBe("agency/feature")
|
|
389
|
+
|
|
390
|
+
// gh should NOT have been called (no error about GitHub PR)
|
|
391
|
+
expect(errorMessages.some((msg) => msg.includes("GitHub PR"))).toBe(false)
|
|
392
|
+
})
|
|
393
|
+
})
|
|
394
|
+
})
|