@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,521 @@
|
|
|
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 { FileSystemService } from "../services/FileSystemService"
|
|
6
|
+
import {
|
|
7
|
+
makeEmitBranchName,
|
|
8
|
+
makeSourceBranchName,
|
|
9
|
+
extractCleanBranch,
|
|
10
|
+
extractCleanFromEmit,
|
|
11
|
+
} from "../utils/pr-branch"
|
|
12
|
+
import {
|
|
13
|
+
getFilesToFilter,
|
|
14
|
+
readAgencyMetadata,
|
|
15
|
+
writeAgencyMetadata,
|
|
16
|
+
} from "../types"
|
|
17
|
+
import highlight, { done } from "../utils/colors"
|
|
18
|
+
import {
|
|
19
|
+
createLoggers,
|
|
20
|
+
ensureGitRepo,
|
|
21
|
+
resolveBaseBranch,
|
|
22
|
+
withBranchProtection,
|
|
23
|
+
} from "../utils/effect"
|
|
24
|
+
import { withSpinner } from "../utils/spinner"
|
|
25
|
+
|
|
26
|
+
interface EmitOptions extends BaseCommandOptions {
|
|
27
|
+
branch?: string
|
|
28
|
+
baseBranch?: string
|
|
29
|
+
force?: boolean
|
|
30
|
+
/** Skip the git-filter-repo step (for testing) */
|
|
31
|
+
skipFilter?: boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const emit = (options: EmitOptions = {}) =>
|
|
35
|
+
Effect.gen(function* () {
|
|
36
|
+
const gitRoot = yield* ensureGitRepo()
|
|
37
|
+
|
|
38
|
+
// Wrap the entire emit operation with branch protection
|
|
39
|
+
// This ensures we return to the original branch on Ctrl-C interrupt
|
|
40
|
+
yield* withBranchProtection(gitRoot, emitCore(gitRoot, options))
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
const emitCore = (gitRoot: string, options: EmitOptions) =>
|
|
44
|
+
Effect.gen(function* () {
|
|
45
|
+
const { force = false, verbose = false, skipFilter = false } = options
|
|
46
|
+
const { log, verboseLog } = createLoggers(options)
|
|
47
|
+
|
|
48
|
+
const git = yield* GitService
|
|
49
|
+
const configService = yield* ConfigService
|
|
50
|
+
const fs = yield* FileSystemService
|
|
51
|
+
|
|
52
|
+
// Check if git-filter-repo is installed
|
|
53
|
+
const hasFilterRepo = yield* git.checkCommandExists("git-filter-repo")
|
|
54
|
+
if (!hasFilterRepo) {
|
|
55
|
+
const isMac = process.platform === "darwin"
|
|
56
|
+
const installInstructions = isMac
|
|
57
|
+
? "Please install it via Homebrew: brew install git-filter-repo"
|
|
58
|
+
: "Please install it using your package manager. See: https://github.com/newren/git-filter-repo/blob/main/INSTALL.md"
|
|
59
|
+
return yield* Effect.fail(
|
|
60
|
+
new Error(`git-filter-repo is not installed. ${installInstructions}`),
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Load config
|
|
65
|
+
const config = yield* configService.loadConfig()
|
|
66
|
+
|
|
67
|
+
// Get current branch
|
|
68
|
+
let currentBranch = yield* git.getCurrentBranch(gitRoot)
|
|
69
|
+
|
|
70
|
+
// Check if current branch is an emit branch (doesn't match source pattern)
|
|
71
|
+
// If so, try to find and switch to the corresponding source branch
|
|
72
|
+
const possibleCleanBranch = extractCleanFromEmit(
|
|
73
|
+
currentBranch,
|
|
74
|
+
config.emitBranch,
|
|
75
|
+
)
|
|
76
|
+
if (possibleCleanBranch) {
|
|
77
|
+
// This looks like an emit branch, try to find the source branch
|
|
78
|
+
const possibleSourceBranch = makeSourceBranchName(
|
|
79
|
+
possibleCleanBranch,
|
|
80
|
+
config.sourceBranchPattern,
|
|
81
|
+
)
|
|
82
|
+
const sourceExists = yield* git.branchExists(
|
|
83
|
+
gitRoot,
|
|
84
|
+
possibleSourceBranch,
|
|
85
|
+
)
|
|
86
|
+
if (sourceExists) {
|
|
87
|
+
// Switch to the source branch and continue
|
|
88
|
+
verboseLog(
|
|
89
|
+
`Currently on emit branch ${highlight.branch(currentBranch)}, switching to source branch ${highlight.branch(possibleSourceBranch)}`,
|
|
90
|
+
)
|
|
91
|
+
yield* git.checkoutBranch(gitRoot, possibleSourceBranch)
|
|
92
|
+
currentBranch = possibleSourceBranch
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// First, check if agency.json already has emitBranch set (source of truth)
|
|
97
|
+
const metadata = yield* Effect.tryPromise({
|
|
98
|
+
try: () => readAgencyMetadata(gitRoot),
|
|
99
|
+
catch: () => null,
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
let emitBranchName: string
|
|
103
|
+
|
|
104
|
+
if (options.branch) {
|
|
105
|
+
// Explicit branch name provided via CLI
|
|
106
|
+
emitBranchName = options.branch
|
|
107
|
+
} else if (metadata?.emitBranch) {
|
|
108
|
+
// Use emitBranch from agency.json (source of truth)
|
|
109
|
+
emitBranchName = metadata.emitBranch
|
|
110
|
+
verboseLog(
|
|
111
|
+
`Using emit branch from agency.json: ${highlight.branch(emitBranchName)}`,
|
|
112
|
+
)
|
|
113
|
+
} else {
|
|
114
|
+
// Compute emit branch name from patterns
|
|
115
|
+
// Extract clean branch from source branch pattern
|
|
116
|
+
// If the branch matches the source pattern, extract the clean name
|
|
117
|
+
// Otherwise, treat the current branch as a "legacy" branch (the clean name itself)
|
|
118
|
+
const cleanBranch =
|
|
119
|
+
extractCleanBranch(currentBranch, config.sourceBranchPattern) ||
|
|
120
|
+
currentBranch
|
|
121
|
+
emitBranchName = makeEmitBranchName(cleanBranch, config.emitBranch)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Check and update agency.json with emitBranch if needed
|
|
125
|
+
yield* ensureEmitBranchInMetadata(gitRoot, currentBranch, emitBranchName)
|
|
126
|
+
|
|
127
|
+
// Find the base branch this was created from
|
|
128
|
+
const baseBranch = yield* resolveBaseBranch(gitRoot, options.baseBranch)
|
|
129
|
+
|
|
130
|
+
verboseLog(`Using base branch: ${highlight.branch(baseBranch)}`)
|
|
131
|
+
|
|
132
|
+
// Get the fork-point (where the branch actually forked from, using reflog)
|
|
133
|
+
// This is more accurate than merge-base because it accounts for rebases
|
|
134
|
+
const forkPoint = yield* findBestForkPoint(
|
|
135
|
+
gitRoot,
|
|
136
|
+
currentBranch,
|
|
137
|
+
baseBranch,
|
|
138
|
+
verbose,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
verboseLog(`Branch forked at commit: ${highlight.commit(forkPoint)}`)
|
|
142
|
+
|
|
143
|
+
// Create or reset emit branch from current branch
|
|
144
|
+
let branchExisted = false
|
|
145
|
+
const createBranch = Effect.gen(function* () {
|
|
146
|
+
const existed = yield* createOrResetBranchEffect(
|
|
147
|
+
gitRoot,
|
|
148
|
+
currentBranch,
|
|
149
|
+
emitBranchName,
|
|
150
|
+
)
|
|
151
|
+
branchExisted = existed
|
|
152
|
+
|
|
153
|
+
// Unset any remote tracking branch for the emit branch
|
|
154
|
+
yield* git.unsetGitConfig(`branch.${emitBranchName}.remote`, gitRoot)
|
|
155
|
+
yield* git.unsetGitConfig(`branch.${emitBranchName}.merge`, gitRoot)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
yield* withSpinner(createBranch, {
|
|
159
|
+
text: `Creating emit branch ${highlight.branch(emitBranchName)}`,
|
|
160
|
+
successText: `${branchExisted ? "Recreated" : "Created"} emit branch ${highlight.branch(emitBranchName)}`,
|
|
161
|
+
enabled: !options.silent && !verbose,
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
// Skip filtering if requested (for testing)
|
|
165
|
+
if (skipFilter) {
|
|
166
|
+
verboseLog("Skipping git-filter-repo (skipFilter=true)")
|
|
167
|
+
// Just switch back to source branch
|
|
168
|
+
yield* git.checkoutBranch(gitRoot, currentBranch)
|
|
169
|
+
log(done(`Emitted ${highlight.branch(emitBranchName)} (filter skipped)`))
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
verboseLog(
|
|
174
|
+
`Filtering backpack files from commits in range: ${highlight.commit(forkPoint.substring(0, 8))}..${highlight.branch(emitBranchName)}`,
|
|
175
|
+
)
|
|
176
|
+
verboseLog(
|
|
177
|
+
`Files will revert to their state at fork-point (base branch: ${highlight.branch(baseBranch)})`,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
// Filter backpack files
|
|
181
|
+
const filterOperation = Effect.gen(function* () {
|
|
182
|
+
// Clean up .git/filter-repo directory
|
|
183
|
+
const filterRepoDir = `${gitRoot}/.git/filter-repo`
|
|
184
|
+
yield* fs.deleteDirectory(filterRepoDir)
|
|
185
|
+
verboseLog("Cleaned up previous git-filter-repo state")
|
|
186
|
+
|
|
187
|
+
// Get files to filter from agency.json metadata
|
|
188
|
+
const filesToFilter = yield* Effect.tryPromise({
|
|
189
|
+
try: () => getFilesToFilter(gitRoot),
|
|
190
|
+
catch: (error) => new Error(`Failed to get files to filter: ${error}`),
|
|
191
|
+
})
|
|
192
|
+
verboseLog(`Files to filter: ${filesToFilter.join(", ")}`)
|
|
193
|
+
|
|
194
|
+
// Run git-filter-repo
|
|
195
|
+
const filterRepoArgs = [
|
|
196
|
+
"git",
|
|
197
|
+
"filter-repo",
|
|
198
|
+
...filesToFilter.flatMap((f) => ["--path", f]),
|
|
199
|
+
"--invert-paths",
|
|
200
|
+
"--force",
|
|
201
|
+
"--refs",
|
|
202
|
+
`${forkPoint}..${emitBranchName}`,
|
|
203
|
+
]
|
|
204
|
+
|
|
205
|
+
const result = yield* git.runGitCommand(filterRepoArgs, gitRoot, {
|
|
206
|
+
env: { GIT_CONFIG_GLOBAL: "" },
|
|
207
|
+
captureOutput: true,
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
verboseLog("git-filter-repo completed")
|
|
211
|
+
|
|
212
|
+
if (result.exitCode !== 0) {
|
|
213
|
+
return yield* Effect.fail(
|
|
214
|
+
new Error(`git-filter-repo failed: ${result.stderr}`),
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Switch back to source branch (git-filter-repo may have checked out the emit branch)
|
|
219
|
+
yield* git.checkoutBranch(gitRoot, currentBranch)
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
yield* withSpinner(filterOperation, {
|
|
223
|
+
text: "Filtering backpack files from branch history",
|
|
224
|
+
successText: "Filtered backpack files from branch history",
|
|
225
|
+
enabled: !options.silent && !verbose,
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
log(done(`Emitted ${highlight.branch(emitBranchName)}`))
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
// Helper: Ensure emitBranch is set in agency.json metadata
|
|
232
|
+
const ensureEmitBranchInMetadata = (
|
|
233
|
+
gitRoot: string,
|
|
234
|
+
currentBranch: string,
|
|
235
|
+
emitBranchName: string,
|
|
236
|
+
) =>
|
|
237
|
+
Effect.gen(function* () {
|
|
238
|
+
const git = yield* GitService
|
|
239
|
+
|
|
240
|
+
// Read existing metadata
|
|
241
|
+
const metadata = yield* Effect.tryPromise({
|
|
242
|
+
try: () => readAgencyMetadata(gitRoot),
|
|
243
|
+
catch: (error) => new Error(`Failed to read agency metadata: ${error}`),
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
// If no metadata exists, skip this step
|
|
247
|
+
if (!metadata) {
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// If emitBranch is already set, nothing to do
|
|
252
|
+
if (metadata.emitBranch) {
|
|
253
|
+
return
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Add emitBranch to metadata
|
|
257
|
+
const updatedMetadata = {
|
|
258
|
+
...metadata,
|
|
259
|
+
emitBranch: emitBranchName,
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Write updated metadata
|
|
263
|
+
yield* Effect.tryPromise({
|
|
264
|
+
try: () => writeAgencyMetadata(gitRoot, updatedMetadata),
|
|
265
|
+
catch: (error) => new Error(`Failed to write agency metadata: ${error}`),
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
// Stage and commit the change
|
|
269
|
+
yield* git.gitAdd(["agency.json"], gitRoot)
|
|
270
|
+
yield* git.gitCommit("chore: agency emit", gitRoot, { noVerify: true })
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Get the fork point for a branch against a reference.
|
|
275
|
+
* Falls back to merge-base if fork-point command fails.
|
|
276
|
+
*/
|
|
277
|
+
const getForkPointOrMergeBase = (
|
|
278
|
+
git: GitService,
|
|
279
|
+
gitRoot: string,
|
|
280
|
+
referenceBranch: string,
|
|
281
|
+
featureBranch: string,
|
|
282
|
+
) =>
|
|
283
|
+
git.getMergeBaseForkPoint(gitRoot, referenceBranch, featureBranch).pipe(
|
|
284
|
+
Effect.catchAll(() =>
|
|
285
|
+
// Fork-point can fail if the reflog doesn't have enough history
|
|
286
|
+
git.getMergeBase(gitRoot, featureBranch, referenceBranch),
|
|
287
|
+
),
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Get the remote tracking branch name for a local branch.
|
|
292
|
+
* Returns null if the branch doesn't track a remote.
|
|
293
|
+
*/
|
|
294
|
+
const getRemoteTrackingBranch = (
|
|
295
|
+
git: GitService,
|
|
296
|
+
gitRoot: string,
|
|
297
|
+
localBranch: string,
|
|
298
|
+
) =>
|
|
299
|
+
Effect.gen(function* () {
|
|
300
|
+
const remote = yield* git
|
|
301
|
+
.getGitConfig(`branch.${localBranch}.remote`, gitRoot)
|
|
302
|
+
.pipe(Effect.option)
|
|
303
|
+
|
|
304
|
+
const remoteBranch = yield* git
|
|
305
|
+
.getGitConfig(`branch.${localBranch}.merge`, gitRoot)
|
|
306
|
+
.pipe(Effect.option)
|
|
307
|
+
|
|
308
|
+
if (
|
|
309
|
+
remote._tag === "Some" &&
|
|
310
|
+
remoteBranch._tag === "Some" &&
|
|
311
|
+
remoteBranch.value
|
|
312
|
+
) {
|
|
313
|
+
return `${remote.value}/${remoteBranch.value.replace("refs/heads/", "")}`
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return null
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Check if one commit is an ancestor of another.
|
|
321
|
+
*/
|
|
322
|
+
const isAncestor = (
|
|
323
|
+
git: GitService,
|
|
324
|
+
gitRoot: string,
|
|
325
|
+
potentialAncestor: string,
|
|
326
|
+
commit: string,
|
|
327
|
+
) =>
|
|
328
|
+
git
|
|
329
|
+
.runGitCommand(
|
|
330
|
+
["git", "merge-base", "--is-ancestor", potentialAncestor, commit],
|
|
331
|
+
gitRoot,
|
|
332
|
+
{},
|
|
333
|
+
)
|
|
334
|
+
.pipe(
|
|
335
|
+
Effect.map((result) => result.exitCode === 0),
|
|
336
|
+
Effect.catchAll(() => Effect.succeed(false)),
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Find the best fork point by checking both local and remote tracking branches.
|
|
341
|
+
*
|
|
342
|
+
* Strategy:
|
|
343
|
+
* 1. Get fork-point against the local base branch
|
|
344
|
+
* 2. If base branch tracks a remote, also get fork-point against remote tracking branch
|
|
345
|
+
* 3. Choose the more recent fork point (prefer remote if local is its ancestor)
|
|
346
|
+
*/
|
|
347
|
+
const findBestForkPoint = (
|
|
348
|
+
gitRoot: string,
|
|
349
|
+
featureBranch: string,
|
|
350
|
+
baseBranch: string,
|
|
351
|
+
verbose: boolean,
|
|
352
|
+
) =>
|
|
353
|
+
Effect.gen(function* () {
|
|
354
|
+
const git = yield* GitService
|
|
355
|
+
const { verboseLog } = createLoggers({ verbose })
|
|
356
|
+
|
|
357
|
+
// Strategy 1: Get fork-point against local base branch
|
|
358
|
+
const localForkPoint = yield* getForkPointOrMergeBase(
|
|
359
|
+
git,
|
|
360
|
+
gitRoot,
|
|
361
|
+
baseBranch,
|
|
362
|
+
featureBranch,
|
|
363
|
+
)
|
|
364
|
+
verboseLog(
|
|
365
|
+
`Fork-point with ${highlight.branch(baseBranch)}: ${highlight.commit(localForkPoint.substring(0, 8))}`,
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
// Strategy 2: Check if base branch tracks a remote
|
|
369
|
+
const remoteTrackingBranch = yield* getRemoteTrackingBranch(
|
|
370
|
+
git,
|
|
371
|
+
gitRoot,
|
|
372
|
+
baseBranch,
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
if (!remoteTrackingBranch) {
|
|
376
|
+
return localForkPoint
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Get fork-point against remote tracking branch
|
|
380
|
+
const remoteForkPoint = yield* getForkPointOrMergeBase(
|
|
381
|
+
git,
|
|
382
|
+
gitRoot,
|
|
383
|
+
remoteTrackingBranch,
|
|
384
|
+
featureBranch,
|
|
385
|
+
)
|
|
386
|
+
verboseLog(
|
|
387
|
+
`Fork-point with ${highlight.branch(remoteTrackingBranch)}: ${highlight.commit(remoteForkPoint.substring(0, 8))}`,
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
// Strategy 3: Choose the more recent fork point
|
|
391
|
+
// If local fork point is an ancestor of remote, prefer remote (it's more recent)
|
|
392
|
+
const localIsAncestorOfRemote = yield* isAncestor(
|
|
393
|
+
git,
|
|
394
|
+
gitRoot,
|
|
395
|
+
localForkPoint,
|
|
396
|
+
remoteForkPoint,
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
if (localIsAncestorOfRemote) {
|
|
400
|
+
verboseLog(
|
|
401
|
+
`Using remote fork-point ${highlight.commit(remoteForkPoint.substring(0, 8))} (local is ancestor)`,
|
|
402
|
+
)
|
|
403
|
+
return remoteForkPoint
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
verboseLog(
|
|
407
|
+
`Using local fork-point ${highlight.commit(localForkPoint.substring(0, 8))}`,
|
|
408
|
+
)
|
|
409
|
+
return localForkPoint
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
// Helper: Create or reset branch
|
|
413
|
+
const createOrResetBranchEffect = (
|
|
414
|
+
gitRoot: string,
|
|
415
|
+
sourceBranch: string,
|
|
416
|
+
targetBranch: string,
|
|
417
|
+
) =>
|
|
418
|
+
Effect.gen(function* () {
|
|
419
|
+
const git = yield* GitService
|
|
420
|
+
|
|
421
|
+
const exists = yield* git.branchExists(gitRoot, targetBranch)
|
|
422
|
+
const currentBranch = yield* git.getCurrentBranch(gitRoot)
|
|
423
|
+
|
|
424
|
+
if (exists) {
|
|
425
|
+
// If we're currently on the target branch, switch away first
|
|
426
|
+
if (currentBranch === targetBranch) {
|
|
427
|
+
yield* git.checkoutBranch(gitRoot, sourceBranch)
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Delete the existing branch
|
|
431
|
+
yield* git.deleteBranch(gitRoot, targetBranch, true)
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Create new branch from source
|
|
435
|
+
yield* git.createBranch(targetBranch, gitRoot, sourceBranch)
|
|
436
|
+
|
|
437
|
+
return exists
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
export const help = `
|
|
441
|
+
Usage: agency emit [base-branch] [options]
|
|
442
|
+
|
|
443
|
+
Create an emit branch from the current source branch with backpack files (AGENTS.md, TASK.md, etc.)
|
|
444
|
+
reverted to their state on the base branch.
|
|
445
|
+
|
|
446
|
+
Source and Emit Branches:
|
|
447
|
+
- Source branches: Your working branches with agency-specific files (e.g., agency/feature-foo)
|
|
448
|
+
- Emit branches: Clean branches suitable for PRs without agency files (e.g., feature-foo)
|
|
449
|
+
|
|
450
|
+
This command creates a clean emit branch from your source branch by filtering out
|
|
451
|
+
backpack files. Your source branch remains completely untouched.
|
|
452
|
+
|
|
453
|
+
Behavior:
|
|
454
|
+
- If a file existed on the base branch: It is reverted to that version
|
|
455
|
+
- If a file did NOT exist on base branch: It is completely removed
|
|
456
|
+
- Only commits since the branch diverged are rewritten
|
|
457
|
+
- This allows you to layer feature-specific instructions on top of base instructions
|
|
458
|
+
during development, then remove those modifications when submitting code
|
|
459
|
+
|
|
460
|
+
Base Branch Selection:
|
|
461
|
+
The command determines the base branch in this order:
|
|
462
|
+
1. Explicitly provided base-branch argument
|
|
463
|
+
2. Branch-specific base branch from agency.json (set by 'agency task')
|
|
464
|
+
3. Repository-level default base branch from .git/config (all branches)
|
|
465
|
+
4. Auto-detected from origin/HEAD or common branches (origin/main, origin/master, etc.)
|
|
466
|
+
|
|
467
|
+
The base branch is set when you run 'agency task' to initialize a feature branch.
|
|
468
|
+
Set a repository-level default with: agency base set --repo <branch>
|
|
469
|
+
Update a branch's base branch with: agency base set <branch>
|
|
470
|
+
|
|
471
|
+
Prerequisites:
|
|
472
|
+
- git-filter-repo must be installed: brew install git-filter-repo
|
|
473
|
+
|
|
474
|
+
Arguments:
|
|
475
|
+
base-branch Base branch to compare against (e.g., origin/main)
|
|
476
|
+
If not provided, will use saved config or prompt interactively
|
|
477
|
+
|
|
478
|
+
Options:
|
|
479
|
+
-b, --branch Custom name for emit branch (defaults to pattern from config)
|
|
480
|
+
-f, --force Force emit branch creation even if current branch looks like an emit branch
|
|
481
|
+
|
|
482
|
+
Configuration:
|
|
483
|
+
~/.config/agency/agency.json can contain:
|
|
484
|
+
{
|
|
485
|
+
"sourceBranchPattern": "agency/%branch%", // Pattern for source branch names
|
|
486
|
+
"emitBranch": "%branch%" // Pattern for emit branch names
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
Use %branch% as placeholder for the clean branch name.
|
|
490
|
+
|
|
491
|
+
Source Pattern Examples:
|
|
492
|
+
"agency/%branch%" -> main becomes agency/main (default)
|
|
493
|
+
"wip/%branch%" -> feature becomes wip/feature
|
|
494
|
+
|
|
495
|
+
Emit Pattern Examples:
|
|
496
|
+
"%branch%" -> agency/main emits to main (default)
|
|
497
|
+
"%branch%--PR" -> agency/feature emits to feature--PR
|
|
498
|
+
"PR/%branch%" -> agency/feature emits to PR/feature
|
|
499
|
+
|
|
500
|
+
Examples:
|
|
501
|
+
agency emit # Prompt for base branch (first time) or use saved
|
|
502
|
+
agency emit origin/main # Explicitly use origin/main as base branch
|
|
503
|
+
agency emit --force # Force creation even from an emit branch
|
|
504
|
+
|
|
505
|
+
Notes:
|
|
506
|
+
- Emit branch is created from your current branch (not the base)
|
|
507
|
+
- Base branch is set when you run 'agency task' to initialize the feature branch
|
|
508
|
+
- Only commits since the branch diverged are rewritten (uses merge-base range)
|
|
509
|
+
- Backpack files are reverted to their merge-base state (or removed if they didn't exist)
|
|
510
|
+
- All commits from the base branch remain unchanged (shared history is preserved)
|
|
511
|
+
- Original branch is never modified
|
|
512
|
+
- If emit branch exists, it will be deleted and recreated
|
|
513
|
+
- Command will refuse to create emit branch from an emit branch unless --force is used
|
|
514
|
+
|
|
515
|
+
Important: If using a remote base branch (e.g., origin/main):
|
|
516
|
+
- Always fetch before rebasing: git fetch origin && git rebase origin/main
|
|
517
|
+
- Or use: git pull --rebase origin main
|
|
518
|
+
- This ensures your local origin/main ref is up to date
|
|
519
|
+
- If origin/main is stale when you rebase and emit, the emit branch may not be
|
|
520
|
+
properly based on the remote, requiring manual rebasing of the emit branch
|
|
521
|
+
`
|