@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,258 @@
|
|
|
1
|
+
import { Effect, DateTime } from "effect"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
import { Schema } from "@effect/schema"
|
|
4
|
+
import type { BaseCommandOptions } from "../utils/command"
|
|
5
|
+
import { GitService } from "../services/GitService"
|
|
6
|
+
import { ConfigService } from "../services/ConfigService"
|
|
7
|
+
import { FileSystemService } from "../services/FileSystemService"
|
|
8
|
+
import { resolveBranchPairWithAgencyJson } from "../utils/pr-branch"
|
|
9
|
+
import { AgencyMetadata } from "../schemas"
|
|
10
|
+
import highlight, { plural } from "../utils/colors"
|
|
11
|
+
import { createLoggers, ensureGitRepo, getTemplateName } from "../utils/effect"
|
|
12
|
+
|
|
13
|
+
interface StatusOptions extends BaseCommandOptions {
|
|
14
|
+
json?: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Branch type for status output
|
|
19
|
+
*/
|
|
20
|
+
type BranchType = "source" | "emit" | "neither"
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Base files that are always in the backpack
|
|
24
|
+
*/
|
|
25
|
+
const BASE_BACKPACK_FILES = ["TASK.md", "AGENCY.md", "agency.json"]
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Status data structure returned by the status command
|
|
29
|
+
*/
|
|
30
|
+
interface StatusData {
|
|
31
|
+
initialized: boolean
|
|
32
|
+
branchType: BranchType
|
|
33
|
+
currentBranch: string
|
|
34
|
+
sourceBranch: string | null
|
|
35
|
+
emitBranch: string | null
|
|
36
|
+
correspondingBranchExists: boolean
|
|
37
|
+
template: string | null
|
|
38
|
+
managedFiles: string[]
|
|
39
|
+
baseBranch: string | null
|
|
40
|
+
createdAt: string | null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Read agency.json metadata from the current working directory.
|
|
45
|
+
*/
|
|
46
|
+
const readAgencyMetadataFromDisk = (gitRoot: string) =>
|
|
47
|
+
Effect.gen(function* () {
|
|
48
|
+
const fs = yield* FileSystemService
|
|
49
|
+
const metadataPath = join(gitRoot, "agency.json")
|
|
50
|
+
|
|
51
|
+
const exists = yield* fs.exists(metadataPath)
|
|
52
|
+
if (!exists) {
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const content = yield* fs.readFile(metadataPath)
|
|
57
|
+
return yield* parseAgencyMetadata(content)
|
|
58
|
+
}).pipe(Effect.catchAll(() => Effect.succeed(null)))
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Read agency.json metadata from a specific branch using git show.
|
|
62
|
+
*/
|
|
63
|
+
const readAgencyMetadataFromBranch = (gitRoot: string, branch: string) =>
|
|
64
|
+
Effect.gen(function* () {
|
|
65
|
+
const git = yield* GitService
|
|
66
|
+
|
|
67
|
+
// Try to read agency.json from the branch using git show
|
|
68
|
+
const result = yield* git.runGitCommand(
|
|
69
|
+
["git", "show", `${branch}:agency.json`],
|
|
70
|
+
gitRoot,
|
|
71
|
+
{ captureOutput: true },
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
if (result.exitCode !== 0 || !result.stdout) {
|
|
75
|
+
return null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return yield* parseAgencyMetadata(result.stdout)
|
|
79
|
+
}).pipe(Effect.catchAll(() => Effect.succeed(null)))
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Parse and validate agency.json content.
|
|
83
|
+
*/
|
|
84
|
+
const parseAgencyMetadata = (content: string) =>
|
|
85
|
+
Effect.gen(function* () {
|
|
86
|
+
const data = yield* Effect.try({
|
|
87
|
+
try: () => JSON.parse(content),
|
|
88
|
+
catch: () => new Error("Failed to parse agency.json"),
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
// Validate version
|
|
92
|
+
if (typeof data.version !== "number" || data.version !== 1) {
|
|
93
|
+
return null
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Parse and validate using Effect schema
|
|
97
|
+
const metadata = yield* Effect.try({
|
|
98
|
+
try: () => Schema.decodeUnknownSync(AgencyMetadata)(data),
|
|
99
|
+
catch: () => new Error("Invalid agency.json format"),
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
return metadata
|
|
103
|
+
}).pipe(Effect.catchAll(() => Effect.succeed(null)))
|
|
104
|
+
|
|
105
|
+
export const status = (options: StatusOptions = {}) =>
|
|
106
|
+
Effect.gen(function* () {
|
|
107
|
+
const { json = false } = options
|
|
108
|
+
const { log } = createLoggers(options)
|
|
109
|
+
|
|
110
|
+
const git = yield* GitService
|
|
111
|
+
const configService = yield* ConfigService
|
|
112
|
+
|
|
113
|
+
const gitRoot = yield* ensureGitRepo()
|
|
114
|
+
|
|
115
|
+
// Load config for emit branch pattern
|
|
116
|
+
const config = yield* configService.loadConfig()
|
|
117
|
+
|
|
118
|
+
// Get current branch
|
|
119
|
+
const currentBranch = yield* git.getCurrentBranch(gitRoot)
|
|
120
|
+
|
|
121
|
+
// Resolve branch pair to determine if we're on source or emit branch
|
|
122
|
+
const branches = yield* resolveBranchPairWithAgencyJson(
|
|
123
|
+
gitRoot,
|
|
124
|
+
currentBranch,
|
|
125
|
+
config.sourceBranchPattern,
|
|
126
|
+
config.emitBranch,
|
|
127
|
+
)
|
|
128
|
+
const { sourceBranch, emitBranch, isOnEmitBranch } = branches
|
|
129
|
+
|
|
130
|
+
// Check if agency is initialized
|
|
131
|
+
// If on emit branch, read agency.json from source branch; otherwise read from disk
|
|
132
|
+
const metadata = isOnEmitBranch
|
|
133
|
+
? yield* readAgencyMetadataFromBranch(gitRoot, sourceBranch)
|
|
134
|
+
: yield* readAgencyMetadataFromDisk(gitRoot)
|
|
135
|
+
const initialized = metadata !== null
|
|
136
|
+
|
|
137
|
+
// Determine branch type
|
|
138
|
+
let branchType: BranchType = "neither"
|
|
139
|
+
if (initialized) {
|
|
140
|
+
branchType = isOnEmitBranch ? "emit" : "source"
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Check if the corresponding branch exists
|
|
144
|
+
const correspondingBranch = isOnEmitBranch ? sourceBranch : emitBranch
|
|
145
|
+
const correspondingBranchExists = yield* git.branchExists(
|
|
146
|
+
gitRoot,
|
|
147
|
+
correspondingBranch,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
// Get template name from git config
|
|
151
|
+
const template = yield* getTemplateName(gitRoot)
|
|
152
|
+
|
|
153
|
+
// Build the complete backpack (all files carried in/out)
|
|
154
|
+
const backpackFiles = metadata
|
|
155
|
+
? [...BASE_BACKPACK_FILES, ...metadata.injectedFiles]
|
|
156
|
+
: []
|
|
157
|
+
|
|
158
|
+
// Build status data
|
|
159
|
+
const statusData: StatusData = {
|
|
160
|
+
initialized,
|
|
161
|
+
branchType,
|
|
162
|
+
currentBranch,
|
|
163
|
+
sourceBranch: isOnEmitBranch ? sourceBranch : currentBranch,
|
|
164
|
+
emitBranch: isOnEmitBranch ? currentBranch : emitBranch,
|
|
165
|
+
correspondingBranchExists,
|
|
166
|
+
template,
|
|
167
|
+
managedFiles: backpackFiles,
|
|
168
|
+
baseBranch: metadata?.baseBranch ?? null,
|
|
169
|
+
createdAt: metadata?.createdAt
|
|
170
|
+
? DateTime.toDateUtc(metadata.createdAt).toISOString()
|
|
171
|
+
: null,
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (json) {
|
|
175
|
+
// Output JSON format
|
|
176
|
+
log(JSON.stringify(statusData, null, 2))
|
|
177
|
+
} else {
|
|
178
|
+
// Output human-readable format with highlighting
|
|
179
|
+
log("")
|
|
180
|
+
|
|
181
|
+
if (!initialized) {
|
|
182
|
+
// Not initialized - show minimal info
|
|
183
|
+
log(
|
|
184
|
+
`Not initialized (run ${highlight.value("agency task")} to initialize)`,
|
|
185
|
+
)
|
|
186
|
+
log(`Current branch: ${highlight.branch(currentBranch)}`)
|
|
187
|
+
if (template) {
|
|
188
|
+
log(`Template: ${highlight.template(template)}`)
|
|
189
|
+
}
|
|
190
|
+
} else {
|
|
191
|
+
// Initialized - show full status
|
|
192
|
+
log(`Current branch: ${highlight.branch(currentBranch)}`)
|
|
193
|
+
|
|
194
|
+
// Branch type
|
|
195
|
+
const branchTypeDisplay = isOnEmitBranch
|
|
196
|
+
? "Emit branch"
|
|
197
|
+
: "Source branch"
|
|
198
|
+
log(`Branch type: ${branchTypeDisplay}`)
|
|
199
|
+
|
|
200
|
+
// Show corresponding branch only if it exists
|
|
201
|
+
if (correspondingBranchExists) {
|
|
202
|
+
if (isOnEmitBranch) {
|
|
203
|
+
log(`Source branch: ${highlight.branch(statusData.sourceBranch!)}`)
|
|
204
|
+
} else {
|
|
205
|
+
log(`Emit branch: ${highlight.branch(statusData.emitBranch!)}`)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Template
|
|
210
|
+
if (template) {
|
|
211
|
+
log(`Template: ${highlight.template(template)}`)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Base branch
|
|
215
|
+
if (metadata?.baseBranch) {
|
|
216
|
+
log(`Base branch: ${highlight.branch(metadata.baseBranch)}`)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Backpack (files carried into the job and will leave with)
|
|
220
|
+
if (backpackFiles.length > 0) {
|
|
221
|
+
log(`Backpack:`)
|
|
222
|
+
for (const file of backpackFiles) {
|
|
223
|
+
log(` ${highlight.file(file)}`)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Created at
|
|
228
|
+
if (metadata?.createdAt) {
|
|
229
|
+
const date = DateTime.toDateUtc(metadata.createdAt)
|
|
230
|
+
log(`Created: ${date.toLocaleString()}`)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
log("")
|
|
235
|
+
}
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
export const help = `
|
|
239
|
+
Usage: agency status [options]
|
|
240
|
+
|
|
241
|
+
Display the current status of the agency setup in this repository.
|
|
242
|
+
|
|
243
|
+
Information shown:
|
|
244
|
+
- Whether agency is initialized (agency.json exists)
|
|
245
|
+
- Current branch and branch type (source, emit, or neither)
|
|
246
|
+
- Source and emit branch names
|
|
247
|
+
- Whether the corresponding branch exists
|
|
248
|
+
- Configured template name
|
|
249
|
+
- Backpack (files carried in and filtered during emit)
|
|
250
|
+
- Base branch and creation timestamp
|
|
251
|
+
|
|
252
|
+
Options:
|
|
253
|
+
--json Output status as JSON for scripting
|
|
254
|
+
|
|
255
|
+
Example:
|
|
256
|
+
agency status # Show human-readable status
|
|
257
|
+
agency status --json # Output as JSON
|
|
258
|
+
`
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { test, expect, describe, beforeEach, afterEach } from "bun:test"
|
|
2
|
+
import { join } from "path"
|
|
3
|
+
import { switchBranch } from "./switch"
|
|
4
|
+
import {
|
|
5
|
+
createTempDir,
|
|
6
|
+
cleanupTempDir,
|
|
7
|
+
initGitRepo,
|
|
8
|
+
getGitOutput,
|
|
9
|
+
getCurrentBranch,
|
|
10
|
+
createCommit,
|
|
11
|
+
checkoutBranch,
|
|
12
|
+
runTestEffect,
|
|
13
|
+
} from "../test-utils"
|
|
14
|
+
|
|
15
|
+
async function createBranch(cwd: string, branchName: string): Promise<void> {
|
|
16
|
+
await Bun.spawn(["git", "checkout", "-b", branchName], {
|
|
17
|
+
cwd,
|
|
18
|
+
stdout: "pipe",
|
|
19
|
+
stderr: "pipe",
|
|
20
|
+
}).exited
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("switch command", () => {
|
|
24
|
+
let tempDir: string
|
|
25
|
+
let originalCwd: string
|
|
26
|
+
|
|
27
|
+
beforeEach(async () => {
|
|
28
|
+
tempDir = await createTempDir()
|
|
29
|
+
originalCwd = process.cwd()
|
|
30
|
+
process.chdir(tempDir)
|
|
31
|
+
|
|
32
|
+
// Set config path to non-existent file to use defaults
|
|
33
|
+
process.env.AGENCY_CONFIG_PATH = join(tempDir, "non-existent-config.json")
|
|
34
|
+
|
|
35
|
+
// Initialize git repo
|
|
36
|
+
await initGitRepo(tempDir)
|
|
37
|
+
await createCommit(tempDir, "Initial commit")
|
|
38
|
+
|
|
39
|
+
// Rename to main if needed
|
|
40
|
+
const currentBranch = await getCurrentBranch(tempDir)
|
|
41
|
+
if (currentBranch === "master") {
|
|
42
|
+
await Bun.spawn(["git", "branch", "-m", "main"], {
|
|
43
|
+
cwd: tempDir,
|
|
44
|
+
stdout: "pipe",
|
|
45
|
+
stderr: "pipe",
|
|
46
|
+
}).exited
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
afterEach(async () => {
|
|
51
|
+
process.chdir(originalCwd)
|
|
52
|
+
delete process.env.AGENCY_CONFIG_PATH
|
|
53
|
+
await cleanupTempDir(tempDir)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe("basic functionality", () => {
|
|
57
|
+
test("switches from emit branch to source branch", async () => {
|
|
58
|
+
// Create source and emit branches (source=agency/main, emit=main)
|
|
59
|
+
await createBranch(tempDir, "agency/main")
|
|
60
|
+
await createCommit(tempDir, "Work on source")
|
|
61
|
+
// Emit branch is just "main" (already exists from setup)
|
|
62
|
+
await checkoutBranch(tempDir, "main")
|
|
63
|
+
|
|
64
|
+
// Run switch command
|
|
65
|
+
await runTestEffect(switchBranch({ silent: true }))
|
|
66
|
+
|
|
67
|
+
// Should be on source branch now
|
|
68
|
+
const currentBranch = await getCurrentBranch(tempDir)
|
|
69
|
+
expect(currentBranch).toBe("agency/main")
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test("switches from source branch to emit branch", async () => {
|
|
73
|
+
// Create source branch (main becomes the emit branch)
|
|
74
|
+
await createBranch(tempDir, "agency/main")
|
|
75
|
+
await createCommit(tempDir, "Work on source")
|
|
76
|
+
|
|
77
|
+
// We're on agency/main (source), switch to main (emit)
|
|
78
|
+
// Run switch command
|
|
79
|
+
await runTestEffect(switchBranch({ silent: true }))
|
|
80
|
+
|
|
81
|
+
// Should be on emit branch now
|
|
82
|
+
const currentBranch = await getCurrentBranch(tempDir)
|
|
83
|
+
expect(currentBranch).toBe("main")
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test("toggles back and forth", async () => {
|
|
87
|
+
// Create source and emit branches
|
|
88
|
+
await createBranch(tempDir, "agency/main")
|
|
89
|
+
await createCommit(tempDir, "Work on source")
|
|
90
|
+
await checkoutBranch(tempDir, "main") // Go to emit
|
|
91
|
+
|
|
92
|
+
// Switch to source
|
|
93
|
+
await runTestEffect(switchBranch({ silent: true }))
|
|
94
|
+
expect(await getCurrentBranch(tempDir)).toBe("agency/main")
|
|
95
|
+
|
|
96
|
+
// Switch back to emit
|
|
97
|
+
await runTestEffect(switchBranch({ silent: true }))
|
|
98
|
+
expect(await getCurrentBranch(tempDir)).toBe("main")
|
|
99
|
+
|
|
100
|
+
// And back to source
|
|
101
|
+
await runTestEffect(switchBranch({ silent: true }))
|
|
102
|
+
expect(await getCurrentBranch(tempDir)).toBe("agency/main")
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test("works with custom emit branch pattern", async () => {
|
|
106
|
+
// Create custom config
|
|
107
|
+
const configPath = join(tempDir, "custom-config.json")
|
|
108
|
+
await Bun.write(
|
|
109
|
+
configPath,
|
|
110
|
+
JSON.stringify({
|
|
111
|
+
sourceBranchPattern: "agency/%branch%",
|
|
112
|
+
emitBranch: "PR/%branch%",
|
|
113
|
+
}),
|
|
114
|
+
)
|
|
115
|
+
process.env.AGENCY_CONFIG_PATH = configPath
|
|
116
|
+
|
|
117
|
+
// Create source branch and its emit branch
|
|
118
|
+
await createBranch(tempDir, "agency/feature")
|
|
119
|
+
await createCommit(tempDir, "Feature work")
|
|
120
|
+
await createBranch(tempDir, "PR/feature")
|
|
121
|
+
|
|
122
|
+
// Switch to source
|
|
123
|
+
await runTestEffect(switchBranch({ silent: true }))
|
|
124
|
+
expect(await getCurrentBranch(tempDir)).toBe("agency/feature")
|
|
125
|
+
|
|
126
|
+
// Switch back to emit
|
|
127
|
+
await runTestEffect(switchBranch({ silent: true }))
|
|
128
|
+
expect(await getCurrentBranch(tempDir)).toBe("PR/feature")
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
describe("error handling", () => {
|
|
133
|
+
test("throws error when emit branch doesn't exist", async () => {
|
|
134
|
+
// Use custom emit pattern so emit branch differs from source
|
|
135
|
+
const configPath = join(tempDir, "custom-config.json")
|
|
136
|
+
await Bun.write(
|
|
137
|
+
configPath,
|
|
138
|
+
JSON.stringify({
|
|
139
|
+
sourceBranchPattern: "agency/%branch%",
|
|
140
|
+
emitBranch: "%branch%--PR",
|
|
141
|
+
}),
|
|
142
|
+
)
|
|
143
|
+
process.env.AGENCY_CONFIG_PATH = configPath
|
|
144
|
+
|
|
145
|
+
// Create source branch, but not the emit branch
|
|
146
|
+
await createBranch(tempDir, "agency/feature")
|
|
147
|
+
// feature--PR doesn't exist
|
|
148
|
+
|
|
149
|
+
await expect(
|
|
150
|
+
runTestEffect(switchBranch({ silent: true })),
|
|
151
|
+
).rejects.toThrow(/Emit branch .* does not exist/)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test("throws error when emit branch doesn't exist", async () => {
|
|
155
|
+
// Create source branch but no emit branch
|
|
156
|
+
await createBranch(tempDir, "agency/feature")
|
|
157
|
+
// We never created 'feature' (emit), so it doesn't exist
|
|
158
|
+
|
|
159
|
+
await expect(
|
|
160
|
+
runTestEffect(switchBranch({ silent: true })),
|
|
161
|
+
).rejects.toThrow(/Emit branch .* does not exist/)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
test("throws error when not in a git repository", async () => {
|
|
165
|
+
const nonGitDir = await createTempDir()
|
|
166
|
+
process.chdir(nonGitDir)
|
|
167
|
+
|
|
168
|
+
await expect(
|
|
169
|
+
runTestEffect(switchBranch({ silent: true })),
|
|
170
|
+
).rejects.toThrow("Not in a git repository")
|
|
171
|
+
|
|
172
|
+
await cleanupTempDir(nonGitDir)
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
describe("silent mode", () => {
|
|
177
|
+
test("silent flag suppresses output", async () => {
|
|
178
|
+
await createBranch(tempDir, "agency/main")
|
|
179
|
+
await checkoutBranch(tempDir, "main")
|
|
180
|
+
|
|
181
|
+
// Capture output
|
|
182
|
+
const originalLog = console.log
|
|
183
|
+
let logCalled = false
|
|
184
|
+
console.log = () => {
|
|
185
|
+
logCalled = true
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
await runTestEffect(switchBranch({ silent: true }))
|
|
189
|
+
|
|
190
|
+
console.log = originalLog
|
|
191
|
+
expect(logCalled).toBe(false)
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
})
|
|
@@ -0,0 +1,84 @@
|
|
|
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 {
|
|
6
|
+
resolveBranchPairWithAgencyJson,
|
|
7
|
+
type BranchPair,
|
|
8
|
+
} from "../utils/pr-branch"
|
|
9
|
+
import highlight, { done } from "../utils/colors"
|
|
10
|
+
import {
|
|
11
|
+
createLoggers,
|
|
12
|
+
ensureGitRepo,
|
|
13
|
+
ensureBranchExists,
|
|
14
|
+
} from "../utils/effect"
|
|
15
|
+
|
|
16
|
+
interface SwitchOptions extends BaseCommandOptions {}
|
|
17
|
+
|
|
18
|
+
export const switchBranch = (options: SwitchOptions = {}) =>
|
|
19
|
+
Effect.gen(function* () {
|
|
20
|
+
const { log } = createLoggers(options)
|
|
21
|
+
|
|
22
|
+
const git = yield* GitService
|
|
23
|
+
const configService = yield* ConfigService
|
|
24
|
+
|
|
25
|
+
const gitRoot = yield* ensureGitRepo()
|
|
26
|
+
|
|
27
|
+
// Load config
|
|
28
|
+
const config = yield* configService.loadConfig()
|
|
29
|
+
|
|
30
|
+
// Get current branch and resolve the branch pair
|
|
31
|
+
const currentBranch = yield* git.getCurrentBranch(gitRoot)
|
|
32
|
+
const branches: BranchPair = yield* resolveBranchPairWithAgencyJson(
|
|
33
|
+
gitRoot,
|
|
34
|
+
currentBranch,
|
|
35
|
+
config.sourceBranchPattern,
|
|
36
|
+
config.emitBranch,
|
|
37
|
+
)
|
|
38
|
+
const { sourceBranch, emitBranch, isOnEmitBranch } = branches
|
|
39
|
+
|
|
40
|
+
if (isOnEmitBranch) {
|
|
41
|
+
// We're on an emit branch, switch to source
|
|
42
|
+
yield* ensureBranchExists(
|
|
43
|
+
gitRoot,
|
|
44
|
+
sourceBranch,
|
|
45
|
+
`Source branch ${highlight.branch(sourceBranch)} does not exist`,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
yield* git.checkoutBranch(gitRoot, sourceBranch)
|
|
49
|
+
log(done(`Switched to source branch: ${highlight.branch(sourceBranch)}`))
|
|
50
|
+
} else {
|
|
51
|
+
// We're on a source branch, switch to emit branch
|
|
52
|
+
yield* ensureBranchExists(
|
|
53
|
+
gitRoot,
|
|
54
|
+
emitBranch,
|
|
55
|
+
`Emit branch ${highlight.branch(emitBranch)} does not exist. Run 'agency emit' to create it.`,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
yield* git.checkoutBranch(gitRoot, emitBranch)
|
|
59
|
+
log(done(`Switched to ${highlight.branch(emitBranch)}`))
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
export const help = `
|
|
64
|
+
Usage: agency switch [options]
|
|
65
|
+
|
|
66
|
+
Toggle between source branch and emit branch.
|
|
67
|
+
|
|
68
|
+
Source and Emit Branches:
|
|
69
|
+
- Source branches: Your working branches with agency-specific files (e.g., agency/main)
|
|
70
|
+
- Emit branches: Clean branches suitable for PRs without agency files (e.g., main)
|
|
71
|
+
|
|
72
|
+
This command intelligently switches between your source branch and its
|
|
73
|
+
corresponding emit branch:
|
|
74
|
+
- If on an emit branch (e.g., main), switches to source (agency/main)
|
|
75
|
+
- If on a source branch (e.g., agency/main), switches to emit branch (main)
|
|
76
|
+
|
|
77
|
+
Example:
|
|
78
|
+
agency switch # Toggle between branches
|
|
79
|
+
|
|
80
|
+
Notes:
|
|
81
|
+
- Target branch must exist
|
|
82
|
+
- Uses source and emit patterns from ~/.config/agency/agency.json
|
|
83
|
+
- If emit branch doesn't exist, run 'agency emit' to create it
|
|
84
|
+
`
|