@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.
Files changed (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +109 -0
  3. package/cli.ts +569 -0
  4. package/index.ts +1 -0
  5. package/package.json +65 -0
  6. package/src/commands/base.test.ts +198 -0
  7. package/src/commands/base.ts +198 -0
  8. package/src/commands/clean.test.ts +299 -0
  9. package/src/commands/clean.ts +320 -0
  10. package/src/commands/emit.test.ts +412 -0
  11. package/src/commands/emit.ts +521 -0
  12. package/src/commands/emitted.test.ts +226 -0
  13. package/src/commands/emitted.ts +57 -0
  14. package/src/commands/init.test.ts +311 -0
  15. package/src/commands/init.ts +140 -0
  16. package/src/commands/merge.test.ts +365 -0
  17. package/src/commands/merge.ts +253 -0
  18. package/src/commands/pull.test.ts +385 -0
  19. package/src/commands/pull.ts +205 -0
  20. package/src/commands/push.test.ts +394 -0
  21. package/src/commands/push.ts +346 -0
  22. package/src/commands/save.test.ts +247 -0
  23. package/src/commands/save.ts +162 -0
  24. package/src/commands/source.test.ts +195 -0
  25. package/src/commands/source.ts +72 -0
  26. package/src/commands/status.test.ts +489 -0
  27. package/src/commands/status.ts +258 -0
  28. package/src/commands/switch.test.ts +194 -0
  29. package/src/commands/switch.ts +84 -0
  30. package/src/commands/task-branching.test.ts +334 -0
  31. package/src/commands/task-edit.test.ts +141 -0
  32. package/src/commands/task-main.test.ts +872 -0
  33. package/src/commands/task.ts +712 -0
  34. package/src/commands/tasks.test.ts +335 -0
  35. package/src/commands/tasks.ts +155 -0
  36. package/src/commands/template-delete.test.ts +178 -0
  37. package/src/commands/template-delete.ts +98 -0
  38. package/src/commands/template-list.test.ts +135 -0
  39. package/src/commands/template-list.ts +87 -0
  40. package/src/commands/template-view.test.ts +158 -0
  41. package/src/commands/template-view.ts +86 -0
  42. package/src/commands/template.test.ts +32 -0
  43. package/src/commands/template.ts +96 -0
  44. package/src/commands/use.test.ts +87 -0
  45. package/src/commands/use.ts +97 -0
  46. package/src/commands/work.test.ts +462 -0
  47. package/src/commands/work.ts +193 -0
  48. package/src/errors.ts +17 -0
  49. package/src/schemas.ts +33 -0
  50. package/src/services/AgencyMetadataService.ts +287 -0
  51. package/src/services/ClaudeService.test.ts +184 -0
  52. package/src/services/ClaudeService.ts +91 -0
  53. package/src/services/ConfigService.ts +115 -0
  54. package/src/services/FileSystemService.ts +222 -0
  55. package/src/services/GitService.ts +751 -0
  56. package/src/services/OpencodeService.ts +263 -0
  57. package/src/services/PromptService.ts +183 -0
  58. package/src/services/TemplateService.ts +75 -0
  59. package/src/test-utils.ts +362 -0
  60. package/src/types/native-exec.d.ts +8 -0
  61. package/src/types.ts +216 -0
  62. package/src/utils/colors.ts +178 -0
  63. package/src/utils/command.ts +17 -0
  64. package/src/utils/effect.ts +281 -0
  65. package/src/utils/exec.ts +48 -0
  66. package/src/utils/paths.ts +51 -0
  67. package/src/utils/pr-branch.test.ts +372 -0
  68. package/src/utils/pr-branch.ts +473 -0
  69. package/src/utils/process.ts +110 -0
  70. package/src/utils/spinner.ts +82 -0
  71. package/templates/AGENCY.md +20 -0
  72. package/templates/AGENTS.md +11 -0
  73. package/templates/CLAUDE.md +3 -0
  74. package/templates/TASK.md +5 -0
  75. 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
+ `