@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,253 @@
|
|
|
1
|
+
import { Effect } from "effect"
|
|
2
|
+
import type { BaseCommandOptions } from "../utils/command"
|
|
3
|
+
import { GitService, GitCommandError } from "../services/GitService"
|
|
4
|
+
import { ConfigService } from "../services/ConfigService"
|
|
5
|
+
import { resolveBranchPairWithAgencyJson } from "../utils/pr-branch"
|
|
6
|
+
import { FileSystemService } from "../services/FileSystemService"
|
|
7
|
+
import { emit } from "./emit"
|
|
8
|
+
import highlight, { done } from "../utils/colors"
|
|
9
|
+
import {
|
|
10
|
+
createLoggers,
|
|
11
|
+
ensureGitRepo,
|
|
12
|
+
ensureBranchExists,
|
|
13
|
+
getBaseBranchFromMetadataEffect,
|
|
14
|
+
getBaseBranchFromBranch,
|
|
15
|
+
getRemoteName,
|
|
16
|
+
} from "../utils/effect"
|
|
17
|
+
|
|
18
|
+
interface MergeOptions extends BaseCommandOptions {
|
|
19
|
+
squash?: boolean
|
|
20
|
+
push?: boolean
|
|
21
|
+
skipFilter?: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Helper to merge a branch using git
|
|
25
|
+
const mergeBranchEffect = (
|
|
26
|
+
gitRoot: string,
|
|
27
|
+
branch: string,
|
|
28
|
+
squash: boolean = false,
|
|
29
|
+
) =>
|
|
30
|
+
Effect.gen(function* () {
|
|
31
|
+
const git = yield* GitService
|
|
32
|
+
const args = squash
|
|
33
|
+
? ["git", "merge", "--squash", branch]
|
|
34
|
+
: ["git", "merge", branch]
|
|
35
|
+
|
|
36
|
+
const result = yield* git.runGitCommand(args, gitRoot, {
|
|
37
|
+
captureOutput: true,
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
if (result.exitCode !== 0) {
|
|
41
|
+
return yield* Effect.fail(
|
|
42
|
+
new GitCommandError({
|
|
43
|
+
command: args.join(" "),
|
|
44
|
+
exitCode: result.exitCode,
|
|
45
|
+
stderr: result.stderr,
|
|
46
|
+
}),
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
export const merge = (options: MergeOptions = {}) =>
|
|
52
|
+
Effect.gen(function* () {
|
|
53
|
+
const { squash = false, push = false, verbose = false } = options
|
|
54
|
+
const { log, verboseLog } = createLoggers(options)
|
|
55
|
+
|
|
56
|
+
const git = yield* GitService
|
|
57
|
+
const configService = yield* ConfigService
|
|
58
|
+
|
|
59
|
+
const gitRoot = yield* ensureGitRepo()
|
|
60
|
+
|
|
61
|
+
const config = yield* configService.loadConfig()
|
|
62
|
+
const currentBranch = yield* git.getCurrentBranch(gitRoot)
|
|
63
|
+
|
|
64
|
+
verboseLog(`Current branch: ${highlight.branch(currentBranch)}`)
|
|
65
|
+
|
|
66
|
+
// Use proper branch resolution with agency.json support
|
|
67
|
+
const fs = yield* FileSystemService
|
|
68
|
+
const branchInfo = yield* resolveBranchPairWithAgencyJson(
|
|
69
|
+
gitRoot,
|
|
70
|
+
currentBranch,
|
|
71
|
+
config.sourceBranchPattern,
|
|
72
|
+
config.emitBranch,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
let emitBranchToMerge: string
|
|
76
|
+
let baseBranchToMergeInto: string
|
|
77
|
+
|
|
78
|
+
if (branchInfo.isOnEmitBranch) {
|
|
79
|
+
const sourceBranch = branchInfo.sourceBranch
|
|
80
|
+
verboseLog(
|
|
81
|
+
`Current branch appears to be an emit branch for source: ${highlight.branch(sourceBranch)}`,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
yield* ensureBranchExists(
|
|
85
|
+
gitRoot,
|
|
86
|
+
sourceBranch,
|
|
87
|
+
`Current branch ${highlight.branch(currentBranch)} appears to be an emit branch, but source branch ${highlight.branch(sourceBranch)} does not exist.\n` +
|
|
88
|
+
`Cannot merge without a valid source branch.`,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
// Get the base branch from the source branch's agency.json using git show
|
|
92
|
+
// No need to checkout the branch - we can read the file directly
|
|
93
|
+
const configuredBase = yield* getBaseBranchFromBranch(
|
|
94
|
+
gitRoot,
|
|
95
|
+
sourceBranch,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
if (!configuredBase) {
|
|
99
|
+
return yield* Effect.fail(
|
|
100
|
+
new Error(
|
|
101
|
+
`No base branch configured for ${highlight.branch(sourceBranch)}.\n` +
|
|
102
|
+
`Please switch to ${highlight.branch(sourceBranch)} and run: agency base set <branch>`,
|
|
103
|
+
),
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
verboseLog(`Configured base branch: ${highlight.branch(configuredBase)}`)
|
|
108
|
+
|
|
109
|
+
// For git operations (checkout/merge), use local branch name
|
|
110
|
+
baseBranchToMergeInto = git.stripRemotePrefix(configuredBase)
|
|
111
|
+
|
|
112
|
+
// Verify local base branch exists
|
|
113
|
+
yield* ensureBranchExists(
|
|
114
|
+
gitRoot,
|
|
115
|
+
baseBranchToMergeInto,
|
|
116
|
+
`Base branch ${highlight.branch(baseBranchToMergeInto)} does not exist locally.\n` +
|
|
117
|
+
`You may need to checkout the branch first or update your base branch configuration.`,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
emitBranchToMerge = currentBranch
|
|
121
|
+
} else {
|
|
122
|
+
// We're on a source branch - need to create/update emit branch first
|
|
123
|
+
verboseLog(
|
|
124
|
+
`Current branch appears to be a source branch, will create emit branch first`,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
// Use the emit branch from branchInfo we already computed
|
|
128
|
+
const emitBranch = branchInfo.emitBranch
|
|
129
|
+
const emitExists = yield* git.branchExists(gitRoot, emitBranch)
|
|
130
|
+
|
|
131
|
+
if (emitExists) {
|
|
132
|
+
verboseLog(
|
|
133
|
+
`Emit branch ${highlight.branch(emitBranch)} already exists, will recreate it`,
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Run 'agency emit' to create/update the emit branch
|
|
138
|
+
verboseLog(`Creating emit branch ${highlight.branch(emitBranch)}...`)
|
|
139
|
+
yield* emit({ silent: true, verbose, skipFilter: options.skipFilter })
|
|
140
|
+
|
|
141
|
+
// emit() leaves us on the source branch, so we can read agency.json directly
|
|
142
|
+
const configuredBase = yield* getBaseBranchFromMetadataEffect(gitRoot)
|
|
143
|
+
if (!configuredBase) {
|
|
144
|
+
return yield* Effect.fail(
|
|
145
|
+
new Error(
|
|
146
|
+
`No base branch configured for ${highlight.branch(currentBranch)}.\n` +
|
|
147
|
+
`Please set one with: agency base set <branch>`,
|
|
148
|
+
),
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
verboseLog(`Configured base branch: ${highlight.branch(configuredBase)}`)
|
|
153
|
+
|
|
154
|
+
// For git operations (checkout/merge), use local branch name
|
|
155
|
+
baseBranchToMergeInto = git.stripRemotePrefix(configuredBase)
|
|
156
|
+
|
|
157
|
+
// Verify local base branch exists
|
|
158
|
+
yield* ensureBranchExists(
|
|
159
|
+
gitRoot,
|
|
160
|
+
baseBranchToMergeInto,
|
|
161
|
+
`Base branch ${highlight.branch(baseBranchToMergeInto)} does not exist locally.\n` +
|
|
162
|
+
`You may need to checkout the branch first or update your base branch configuration.`,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
emitBranchToMerge = emitBranch
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Now switch to the base branch
|
|
169
|
+
verboseLog(`Switching to ${highlight.branch(baseBranchToMergeInto)}...`)
|
|
170
|
+
yield* git.checkoutBranch(gitRoot, baseBranchToMergeInto)
|
|
171
|
+
|
|
172
|
+
// Merge the emit branch
|
|
173
|
+
verboseLog(
|
|
174
|
+
`Merging ${highlight.branch(emitBranchToMerge)} into ${highlight.branch(baseBranchToMergeInto)}${squash ? " (squash)" : ""}...`,
|
|
175
|
+
)
|
|
176
|
+
yield* mergeBranchEffect(gitRoot, emitBranchToMerge, squash)
|
|
177
|
+
|
|
178
|
+
if (squash) {
|
|
179
|
+
log(done(`Squash merged (awaiting commit)`))
|
|
180
|
+
} else {
|
|
181
|
+
log(done("Merged"))
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Push the base branch if --push flag is set
|
|
185
|
+
if (push) {
|
|
186
|
+
const remote = yield* getRemoteName(gitRoot)
|
|
187
|
+
verboseLog(
|
|
188
|
+
`Pushing ${highlight.branch(baseBranchToMergeInto)} to ${highlight.remote(remote)}...`,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
const pushResult = yield* git.runGitCommand(
|
|
192
|
+
["git", "push", remote, baseBranchToMergeInto],
|
|
193
|
+
gitRoot,
|
|
194
|
+
{ captureOutput: true },
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
if (pushResult.exitCode !== 0) {
|
|
198
|
+
return yield* Effect.fail(
|
|
199
|
+
new GitCommandError({
|
|
200
|
+
command: `git push ${remote} ${baseBranchToMergeInto}`,
|
|
201
|
+
exitCode: pushResult.exitCode,
|
|
202
|
+
stderr: pushResult.stderr,
|
|
203
|
+
}),
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
log(done(`Pushed to ${highlight.remote(remote)}`))
|
|
208
|
+
}
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
export const help = `
|
|
212
|
+
Usage: agency merge [options]
|
|
213
|
+
|
|
214
|
+
Merge the current emit branch into the configured base branch.
|
|
215
|
+
|
|
216
|
+
This command handles two scenarios:
|
|
217
|
+
1. If on an emit branch (e.g., feature--PR): Switches to the base branch and merges the emit branch
|
|
218
|
+
2. If on a source branch (e.g., feature): Runs 'agency emit' first to create/update the emit branch, then merges it
|
|
219
|
+
|
|
220
|
+
Behavior:
|
|
221
|
+
- Automatically detects whether you're on a source or emit branch
|
|
222
|
+
- Retrieves the configured base branch (e.g., 'main') from git config
|
|
223
|
+
- Switches to the base branch
|
|
224
|
+
- Merges the emit branch into the base branch
|
|
225
|
+
- Leaves you on the base branch after merge
|
|
226
|
+
|
|
227
|
+
This is useful for local development workflows where you want to test merging
|
|
228
|
+
your clean emit branch (without AGENTS.md modifications) into the base branch
|
|
229
|
+
before pushing.
|
|
230
|
+
|
|
231
|
+
Prerequisites:
|
|
232
|
+
- Must be on either a source branch or its corresponding emit branch
|
|
233
|
+
- Base branch must exist locally
|
|
234
|
+
- For source branches: Must have a corresponding emit branch or be able to create one
|
|
235
|
+
|
|
236
|
+
Options:
|
|
237
|
+
--squash # Use squash merge instead of regular merge
|
|
238
|
+
--push # Push the base branch to origin after merging
|
|
239
|
+
|
|
240
|
+
Examples:
|
|
241
|
+
agency merge # From source branch: creates emit branch then merges
|
|
242
|
+
agency merge --squash # Squash merge (stages changes, requires manual commit)
|
|
243
|
+
agency merge --push # Merge and push the base branch to origin
|
|
244
|
+
|
|
245
|
+
Notes:
|
|
246
|
+
- The command determines the base branch from git config (agency.pr.<branch>.baseBranch)
|
|
247
|
+
- If you're on a source branch, 'agency emit' is run automatically
|
|
248
|
+
- The emit branch must have both a source branch and base branch configured
|
|
249
|
+
- After merge, you remain on the base branch
|
|
250
|
+
- Merge conflicts must be resolved manually if they occur
|
|
251
|
+
- With --squash, changes are staged but not committed (you must commit manually)
|
|
252
|
+
- With --push, the base branch is pushed to origin after a successful merge
|
|
253
|
+
`
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import { test, expect, describe, beforeEach, afterEach } from "bun:test"
|
|
2
|
+
import { join } from "path"
|
|
3
|
+
import { pull } from "./pull"
|
|
4
|
+
import {
|
|
5
|
+
createTempDir,
|
|
6
|
+
cleanupTempDir,
|
|
7
|
+
initGitRepo,
|
|
8
|
+
getCurrentBranch,
|
|
9
|
+
createCommit,
|
|
10
|
+
checkoutBranch,
|
|
11
|
+
runTestEffect,
|
|
12
|
+
} from "../test-utils"
|
|
13
|
+
|
|
14
|
+
async function createBranch(cwd: string, branchName: string): Promise<void> {
|
|
15
|
+
await Bun.spawn(["git", "checkout", "-b", branchName], {
|
|
16
|
+
cwd,
|
|
17
|
+
stdout: "pipe",
|
|
18
|
+
stderr: "pipe",
|
|
19
|
+
}).exited
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function setupAgencyJson(
|
|
23
|
+
gitRoot: string,
|
|
24
|
+
emitBranch?: string,
|
|
25
|
+
): Promise<void> {
|
|
26
|
+
const agencyJson = {
|
|
27
|
+
version: 1,
|
|
28
|
+
injectedFiles: ["AGENTS.MD", "TASK.md"],
|
|
29
|
+
template: "test",
|
|
30
|
+
createdAt: new Date().toISOString(),
|
|
31
|
+
...(emitBranch ? { emitBranch } : {}),
|
|
32
|
+
}
|
|
33
|
+
await Bun.write(
|
|
34
|
+
join(gitRoot, "agency.json"),
|
|
35
|
+
JSON.stringify(agencyJson, null, 2) + "\n",
|
|
36
|
+
)
|
|
37
|
+
await Bun.spawn(["git", "add", "agency.json"], {
|
|
38
|
+
cwd: gitRoot,
|
|
39
|
+
stdout: "pipe",
|
|
40
|
+
stderr: "pipe",
|
|
41
|
+
}).exited
|
|
42
|
+
await createCommit(gitRoot, "Add agency.json")
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function setupBareRemote(tempDir: string): Promise<string> {
|
|
46
|
+
// Create a bare repository to use as remote
|
|
47
|
+
const remoteDir = join(tempDir, "remote.git")
|
|
48
|
+
await Bun.spawn(["git", "init", "--bare", remoteDir], {
|
|
49
|
+
stdout: "pipe",
|
|
50
|
+
stderr: "pipe",
|
|
51
|
+
}).exited
|
|
52
|
+
|
|
53
|
+
return remoteDir
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function addRemote(cwd: string, remoteUrl: string): Promise<void> {
|
|
57
|
+
await Bun.spawn(["git", "remote", "add", "origin", remoteUrl], {
|
|
58
|
+
cwd,
|
|
59
|
+
stdout: "pipe",
|
|
60
|
+
stderr: "pipe",
|
|
61
|
+
}).exited
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function getCommitCount(cwd: string, branch: string): Promise<number> {
|
|
65
|
+
const proc = Bun.spawn(["git", "rev-list", "--count", branch], {
|
|
66
|
+
cwd,
|
|
67
|
+
stdout: "pipe",
|
|
68
|
+
stderr: "pipe",
|
|
69
|
+
})
|
|
70
|
+
await proc.exited
|
|
71
|
+
const output = await new Response(proc.stdout).text()
|
|
72
|
+
return parseInt(output.trim(), 10)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function getLatestCommitMessage(cwd: string): Promise<string> {
|
|
76
|
+
const proc = Bun.spawn(["git", "log", "-1", "--pretty=%B"], {
|
|
77
|
+
cwd,
|
|
78
|
+
stdout: "pipe",
|
|
79
|
+
stderr: "pipe",
|
|
80
|
+
})
|
|
81
|
+
await proc.exited
|
|
82
|
+
const output = await new Response(proc.stdout).text()
|
|
83
|
+
return output.trim()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
describe("pull command", () => {
|
|
87
|
+
let tempDir: string
|
|
88
|
+
let remoteDir: string
|
|
89
|
+
let originalCwd: string
|
|
90
|
+
|
|
91
|
+
beforeEach(async () => {
|
|
92
|
+
tempDir = await createTempDir()
|
|
93
|
+
originalCwd = process.cwd()
|
|
94
|
+
process.chdir(tempDir)
|
|
95
|
+
|
|
96
|
+
// Set config path to non-existent file to use defaults
|
|
97
|
+
process.env.AGENCY_CONFIG_PATH = join(tempDir, "non-existent-config.json")
|
|
98
|
+
|
|
99
|
+
// Initialize git repo
|
|
100
|
+
await initGitRepo(tempDir)
|
|
101
|
+
await createCommit(tempDir, "Initial commit")
|
|
102
|
+
|
|
103
|
+
// Rename to main if needed
|
|
104
|
+
const currentBranch = await getCurrentBranch(tempDir)
|
|
105
|
+
if (currentBranch === "master") {
|
|
106
|
+
await Bun.spawn(["git", "branch", "-m", "main"], {
|
|
107
|
+
cwd: tempDir,
|
|
108
|
+
stdout: "pipe",
|
|
109
|
+
stderr: "pipe",
|
|
110
|
+
}).exited
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Setup bare remote
|
|
114
|
+
remoteDir = await setupBareRemote(tempDir)
|
|
115
|
+
await addRemote(tempDir, remoteDir)
|
|
116
|
+
|
|
117
|
+
// Push main to remote
|
|
118
|
+
await Bun.spawn(["git", "push", "-u", "origin", "main"], {
|
|
119
|
+
cwd: tempDir,
|
|
120
|
+
stdout: "pipe",
|
|
121
|
+
stderr: "pipe",
|
|
122
|
+
}).exited
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
afterEach(async () => {
|
|
126
|
+
process.chdir(originalCwd)
|
|
127
|
+
delete process.env.AGENCY_CONFIG_PATH
|
|
128
|
+
await cleanupTempDir(tempDir)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
describe("basic functionality", () => {
|
|
132
|
+
test("pulls commits from remote emit branch to source branch", async () => {
|
|
133
|
+
// Setup agency.json
|
|
134
|
+
await setupAgencyJson(tempDir)
|
|
135
|
+
|
|
136
|
+
// Create source branch: A1 A2
|
|
137
|
+
await createBranch(tempDir, "agency/feature")
|
|
138
|
+
await createCommit(tempDir, "A1: Feature work")
|
|
139
|
+
|
|
140
|
+
// Simulate agency emit: create local emit branch (B1) with rewritten history
|
|
141
|
+
await createBranch(tempDir, "feature")
|
|
142
|
+
// For test simplicity, we're not actually rewriting history, just creating emit branch
|
|
143
|
+
|
|
144
|
+
// Push emit branch to remote
|
|
145
|
+
await Bun.spawn(["git", "push", "-u", "origin", "feature"], {
|
|
146
|
+
cwd: tempDir,
|
|
147
|
+
stdout: "pipe",
|
|
148
|
+
stderr: "pipe",
|
|
149
|
+
}).exited
|
|
150
|
+
|
|
151
|
+
// Now local emit and remote emit are in sync (both at B1)
|
|
152
|
+
// Simulate someone adding commits to remote emit branch (B2, B3)
|
|
153
|
+
await Bun.write(join(tempDir, "file1.txt"), "content1")
|
|
154
|
+
await Bun.spawn(["git", "add", "file1.txt"], {
|
|
155
|
+
cwd: tempDir,
|
|
156
|
+
stdout: "pipe",
|
|
157
|
+
stderr: "pipe",
|
|
158
|
+
}).exited
|
|
159
|
+
await createCommit(tempDir, "B2: Remote commit 1")
|
|
160
|
+
|
|
161
|
+
await Bun.write(join(tempDir, "file2.txt"), "content2")
|
|
162
|
+
await Bun.spawn(["git", "add", "file2.txt"], {
|
|
163
|
+
cwd: tempDir,
|
|
164
|
+
stdout: "pipe",
|
|
165
|
+
stderr: "pipe",
|
|
166
|
+
}).exited
|
|
167
|
+
await createCommit(tempDir, "B3: Remote commit 2")
|
|
168
|
+
|
|
169
|
+
await Bun.spawn(["git", "push"], {
|
|
170
|
+
cwd: tempDir,
|
|
171
|
+
stdout: "pipe",
|
|
172
|
+
stderr: "pipe",
|
|
173
|
+
}).exited
|
|
174
|
+
|
|
175
|
+
// Reset local emit branch to B1 (before the remote commits B2, B3)
|
|
176
|
+
// This simulates the state where remote has moved ahead
|
|
177
|
+
await Bun.spawn(["git", "reset", "--hard", "HEAD~2"], {
|
|
178
|
+
cwd: tempDir,
|
|
179
|
+
stdout: "pipe",
|
|
180
|
+
stderr: "pipe",
|
|
181
|
+
}).exited
|
|
182
|
+
|
|
183
|
+
// Go back to source branch (still at A1)
|
|
184
|
+
await checkoutBranch(tempDir, "agency/feature")
|
|
185
|
+
const beforeCommitCount = await getCommitCount(tempDir, "agency/feature")
|
|
186
|
+
|
|
187
|
+
// Run pull command - should find B2 and B3 on remote that aren't on local emit
|
|
188
|
+
// and cherry-pick them onto source as A2 and A3
|
|
189
|
+
await runTestEffect(pull({ silent: true }))
|
|
190
|
+
|
|
191
|
+
// Should still be on source branch
|
|
192
|
+
expect(await getCurrentBranch(tempDir)).toBe("agency/feature")
|
|
193
|
+
|
|
194
|
+
// Should have the new commits
|
|
195
|
+
const afterCommitCount = await getCommitCount(tempDir, "agency/feature")
|
|
196
|
+
expect(afterCommitCount).toBe(beforeCommitCount + 2)
|
|
197
|
+
|
|
198
|
+
// Last commit should be the second remote commit
|
|
199
|
+
const lastCommit = await getLatestCommitMessage(tempDir)
|
|
200
|
+
expect(lastCommit).toBe("B3: Remote commit 2")
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
test("handles no new commits gracefully", async () => {
|
|
204
|
+
// Setup agency.json
|
|
205
|
+
await setupAgencyJson(tempDir)
|
|
206
|
+
|
|
207
|
+
// Create a source branch
|
|
208
|
+
await createBranch(tempDir, "agency/feature")
|
|
209
|
+
await createCommit(tempDir, "Feature work")
|
|
210
|
+
|
|
211
|
+
// Create emit branch with same commits
|
|
212
|
+
await createBranch(tempDir, "feature")
|
|
213
|
+
|
|
214
|
+
// Push emit branch to remote
|
|
215
|
+
await Bun.spawn(["git", "push", "-u", "origin", "feature"], {
|
|
216
|
+
cwd: tempDir,
|
|
217
|
+
stdout: "pipe",
|
|
218
|
+
stderr: "pipe",
|
|
219
|
+
}).exited
|
|
220
|
+
|
|
221
|
+
// Go back to source branch
|
|
222
|
+
await checkoutBranch(tempDir, "agency/feature")
|
|
223
|
+
|
|
224
|
+
// Capture output
|
|
225
|
+
const originalLog = console.log
|
|
226
|
+
let logMessages: string[] = []
|
|
227
|
+
console.log = (msg: string) => {
|
|
228
|
+
logMessages.push(msg)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Run pull command - should report no new commits
|
|
232
|
+
await runTestEffect(pull({ silent: false }))
|
|
233
|
+
|
|
234
|
+
console.log = originalLog
|
|
235
|
+
|
|
236
|
+
// Should report no new commits
|
|
237
|
+
expect(logMessages.some((msg) => msg.includes("No new commits"))).toBe(
|
|
238
|
+
true,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
// Should still be on source branch
|
|
242
|
+
expect(await getCurrentBranch(tempDir)).toBe("agency/feature")
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
test("works with custom remote", async () => {
|
|
246
|
+
// Setup agency.json
|
|
247
|
+
await setupAgencyJson(tempDir)
|
|
248
|
+
|
|
249
|
+
// Create a source branch
|
|
250
|
+
await createBranch(tempDir, "agency/feature")
|
|
251
|
+
await createCommit(tempDir, "Feature work")
|
|
252
|
+
|
|
253
|
+
// Create local emit branch
|
|
254
|
+
await createBranch(tempDir, "feature")
|
|
255
|
+
|
|
256
|
+
// Add a second remote
|
|
257
|
+
const upstreamDir = await setupBareRemote(tempDir)
|
|
258
|
+
await Bun.spawn(["git", "remote", "add", "upstream", upstreamDir], {
|
|
259
|
+
cwd: tempDir,
|
|
260
|
+
stdout: "pipe",
|
|
261
|
+
stderr: "pipe",
|
|
262
|
+
}).exited
|
|
263
|
+
|
|
264
|
+
// Push emit branch to upstream
|
|
265
|
+
await Bun.spawn(["git", "push", "-u", "upstream", "feature"], {
|
|
266
|
+
cwd: tempDir,
|
|
267
|
+
stdout: "pipe",
|
|
268
|
+
stderr: "pipe",
|
|
269
|
+
}).exited
|
|
270
|
+
|
|
271
|
+
// Add a commit to upstream remote
|
|
272
|
+
await Bun.write(join(tempDir, "upstream-file.txt"), "upstream content")
|
|
273
|
+
await Bun.spawn(["git", "add", "upstream-file.txt"], {
|
|
274
|
+
cwd: tempDir,
|
|
275
|
+
stdout: "pipe",
|
|
276
|
+
stderr: "pipe",
|
|
277
|
+
}).exited
|
|
278
|
+
await createCommit(tempDir, "Emit commit")
|
|
279
|
+
await Bun.spawn(["git", "push", "upstream", "feature"], {
|
|
280
|
+
cwd: tempDir,
|
|
281
|
+
stdout: "pipe",
|
|
282
|
+
stderr: "pipe",
|
|
283
|
+
}).exited
|
|
284
|
+
|
|
285
|
+
// Reset local emit branch to before the upstream commit
|
|
286
|
+
await Bun.spawn(["git", "reset", "--hard", "HEAD~1"], {
|
|
287
|
+
cwd: tempDir,
|
|
288
|
+
stdout: "pipe",
|
|
289
|
+
stderr: "pipe",
|
|
290
|
+
}).exited
|
|
291
|
+
|
|
292
|
+
// Go back to source branch
|
|
293
|
+
await checkoutBranch(tempDir, "agency/feature")
|
|
294
|
+
|
|
295
|
+
// Run pull with custom remote
|
|
296
|
+
await runTestEffect(pull({ remote: "upstream", silent: true }))
|
|
297
|
+
|
|
298
|
+
// Should still be on source branch
|
|
299
|
+
expect(await getCurrentBranch(tempDir)).toBe("agency/feature")
|
|
300
|
+
|
|
301
|
+
// Should have the new commit
|
|
302
|
+
const lastCommit = await getLatestCommitMessage(tempDir)
|
|
303
|
+
expect(lastCommit).toBe("Emit commit")
|
|
304
|
+
})
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
describe("error handling", () => {
|
|
308
|
+
test("throws error when not in a git repository", async () => {
|
|
309
|
+
const nonGitDir = await createTempDir()
|
|
310
|
+
process.chdir(nonGitDir)
|
|
311
|
+
|
|
312
|
+
await expect(runTestEffect(pull({ silent: true }))).rejects.toThrow(
|
|
313
|
+
"Not in a git repository",
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
await cleanupTempDir(nonGitDir)
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
test("handles case when only source branch exists (no emit on remote)", async () => {
|
|
320
|
+
// Setup agency.json
|
|
321
|
+
await setupAgencyJson(tempDir)
|
|
322
|
+
|
|
323
|
+
// Create a source branch
|
|
324
|
+
await createBranch(tempDir, "agency/feature")
|
|
325
|
+
await setupAgencyJson(tempDir, "feature")
|
|
326
|
+
await createCommit(tempDir, "Feature work")
|
|
327
|
+
|
|
328
|
+
// Don't create or push emit branch - just test that pull handles this gracefully
|
|
329
|
+
// Run pull - should fail because remote emit branch doesn't exist
|
|
330
|
+
await expect(runTestEffect(pull({ silent: true }))).rejects.toThrow()
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
test("throws error when remote emit branch does not exist", async () => {
|
|
334
|
+
// Setup agency.json
|
|
335
|
+
await setupAgencyJson(tempDir)
|
|
336
|
+
|
|
337
|
+
// Create a source branch
|
|
338
|
+
await createBranch(tempDir, "agency/feature")
|
|
339
|
+
await setupAgencyJson(tempDir, "feature")
|
|
340
|
+
await createCommit(tempDir, "Feature work")
|
|
341
|
+
|
|
342
|
+
// Run pull - should fail because remote emit branch doesn't exist
|
|
343
|
+
await expect(runTestEffect(pull({ silent: true }))).rejects.toThrow(
|
|
344
|
+
"Failed to fetch",
|
|
345
|
+
)
|
|
346
|
+
})
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
describe("silent mode", () => {
|
|
350
|
+
test("silent flag suppresses output", async () => {
|
|
351
|
+
// Setup agency.json
|
|
352
|
+
await setupAgencyJson(tempDir)
|
|
353
|
+
|
|
354
|
+
// Create a source branch
|
|
355
|
+
await createBranch(tempDir, "agency/feature")
|
|
356
|
+
await createCommit(tempDir, "Feature work")
|
|
357
|
+
|
|
358
|
+
// Create emit branch
|
|
359
|
+
await createBranch(tempDir, "feature")
|
|
360
|
+
await createCommit(tempDir, "Emit commit")
|
|
361
|
+
|
|
362
|
+
// Push emit branch to remote
|
|
363
|
+
await Bun.spawn(["git", "push", "-u", "origin", "feature"], {
|
|
364
|
+
cwd: tempDir,
|
|
365
|
+
stdout: "pipe",
|
|
366
|
+
stderr: "pipe",
|
|
367
|
+
}).exited
|
|
368
|
+
|
|
369
|
+
// Go back to source branch
|
|
370
|
+
await checkoutBranch(tempDir, "agency/feature")
|
|
371
|
+
|
|
372
|
+
// Capture output
|
|
373
|
+
const originalLog = console.log
|
|
374
|
+
let logCalled = false
|
|
375
|
+
console.log = () => {
|
|
376
|
+
logCalled = true
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
await runTestEffect(pull({ silent: true }))
|
|
380
|
+
|
|
381
|
+
console.log = originalLog
|
|
382
|
+
expect(logCalled).toBe(false)
|
|
383
|
+
})
|
|
384
|
+
})
|
|
385
|
+
})
|