@markjaquith/agency 0.5.1 → 0.6.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.
@@ -0,0 +1,243 @@
1
+ import { Effect } from "effect"
2
+ import type { BaseCommandOptions } from "../utils/command"
3
+ import { GitService } from "../services/GitService"
4
+ import { FileSystemService } from "../services/FileSystemService"
5
+ import {
6
+ AgencyMetadataService,
7
+ AgencyMetadataServiceLive,
8
+ } from "../services/AgencyMetadataService"
9
+ import highlight, { done, info } from "../utils/colors"
10
+ import {
11
+ createLoggers,
12
+ ensureGitRepo,
13
+ resolveBaseBranch,
14
+ withBranchProtection,
15
+ } from "../utils/effect"
16
+ import { withSpinner } from "../utils/spinner"
17
+
18
+ interface RebaseOptions extends BaseCommandOptions {
19
+ baseBranch?: string // Internal option, populated from positional arg
20
+ emit?: string // Custom emit branch name to set after rebase
21
+ branch?: string // Deprecated: use emit instead
22
+ }
23
+
24
+ export const rebase = (options: RebaseOptions = {}) =>
25
+ Effect.gen(function* () {
26
+ const gitRoot = yield* ensureGitRepo()
27
+
28
+ // Wrap the entire rebase operation with branch protection
29
+ yield* withBranchProtection(gitRoot, rebaseCore(gitRoot, options))
30
+ })
31
+
32
+ const rebaseCore = (gitRoot: string, options: RebaseOptions) =>
33
+ Effect.gen(function* () {
34
+ const { verbose = false } = options
35
+ const { log, verboseLog } = createLoggers(options)
36
+
37
+ const git = yield* GitService
38
+
39
+ // Get current branch
40
+ const currentBranch = yield* git.getCurrentBranch(gitRoot)
41
+ verboseLog(`Current branch: ${highlight.branch(currentBranch)}`)
42
+
43
+ // Check if current branch has agency.json (is a source branch)
44
+ const metadataService = yield* Effect.gen(function* () {
45
+ return yield* AgencyMetadataService
46
+ }).pipe(Effect.provide(AgencyMetadataServiceLive))
47
+
48
+ const metadata = yield* metadataService.readFromDisk(gitRoot)
49
+
50
+ if (!metadata) {
51
+ return yield* Effect.fail(
52
+ new Error(
53
+ `Current branch ${highlight.branch(currentBranch)} does not have agency.json.\n` +
54
+ `The rebase command can only be used on agency source branches.\n` +
55
+ `Run 'agency task' first to initialize this branch, or switch to an existing agency branch.`,
56
+ ),
57
+ )
58
+ }
59
+
60
+ verboseLog(`Branch is an agency source branch`)
61
+
62
+ // Check for uncommitted changes
63
+ const statusResult = yield* git.runGitCommand(
64
+ ["git", "status", "--porcelain"],
65
+ gitRoot,
66
+ { captureOutput: true },
67
+ )
68
+
69
+ if (statusResult.stdout && statusResult.stdout.trim().length > 0) {
70
+ return yield* Effect.fail(
71
+ new Error(
72
+ `You have uncommitted changes. Please commit or stash them before rebasing.\n` +
73
+ `Run 'git status' to see the changes.`,
74
+ ),
75
+ )
76
+ }
77
+
78
+ verboseLog(`Working directory is clean`)
79
+
80
+ // Resolve base branch (what we're rebasing onto)
81
+ const baseBranch = yield* resolveBaseBranch(gitRoot, options.baseBranch)
82
+ verboseLog(`Base branch: ${highlight.branch(baseBranch)}`)
83
+
84
+ // Check if base branch is a remote branch
85
+ const hasRemotePrefix = yield* git.hasRemotePrefix(baseBranch, gitRoot)
86
+
87
+ if (hasRemotePrefix) {
88
+ // Extract remote name from branch (e.g., "origin/main" -> "origin")
89
+ const remote = yield* git.getRemoteFromBranch(baseBranch, gitRoot)
90
+
91
+ if (remote) {
92
+ // Fetch the remote to ensure we have the latest commits
93
+ const fetchOperation = Effect.gen(function* () {
94
+ verboseLog(`Fetching ${highlight.branch(remote)}`)
95
+ yield* git.fetch(gitRoot, remote)
96
+ verboseLog(`Fetched ${highlight.branch(remote)}`)
97
+ })
98
+
99
+ yield* withSpinner(fetchOperation, {
100
+ text: `Fetching ${highlight.branch(remote)}`,
101
+ successText: `Fetched ${highlight.branch(remote)}`,
102
+ enabled: !options.silent && !verbose,
103
+ })
104
+ }
105
+ }
106
+
107
+ // Perform the rebase
108
+ log(
109
+ info(
110
+ `Rebasing ${highlight.branch(currentBranch)} onto ${highlight.branch(baseBranch)}`,
111
+ ),
112
+ )
113
+
114
+ const rebaseOperation = Effect.gen(function* () {
115
+ verboseLog(`Running: git rebase ${baseBranch}`)
116
+
117
+ const result = yield* git.runGitCommand(
118
+ ["git", "rebase", baseBranch],
119
+ gitRoot,
120
+ { captureOutput: true },
121
+ )
122
+
123
+ if (result.exitCode !== 0) {
124
+ return yield* Effect.fail(
125
+ new Error(
126
+ `Rebase failed with conflicts.\n\n` +
127
+ `${result.stderr}\n\n` +
128
+ `To resolve:\n` +
129
+ ` 1. Fix the conflicts in your files\n` +
130
+ ` 2. Run: git add <resolved-files>\n` +
131
+ ` 3. Run: git rebase --continue\n\n` +
132
+ `To abort the rebase:\n` +
133
+ ` Run: git rebase --abort`,
134
+ ),
135
+ )
136
+ }
137
+
138
+ verboseLog(`Rebase completed successfully`)
139
+ })
140
+
141
+ yield* withSpinner(rebaseOperation, {
142
+ text: "Rebasing branch",
143
+ successText: "Rebased branch successfully",
144
+ enabled: !options.silent && !verbose,
145
+ })
146
+
147
+ log(
148
+ done(
149
+ `Rebased ${highlight.branch(currentBranch)} onto ${highlight.branch(baseBranch)}`,
150
+ ),
151
+ )
152
+
153
+ // Update emit branch in agency.json if --emit/--branch flag was provided
154
+ const newEmitBranch = options.emit || options.branch
155
+ if (newEmitBranch && metadata) {
156
+ verboseLog(`Updating emit branch to: ${highlight.branch(newEmitBranch)}`)
157
+
158
+ // Update metadata with new emit branch
159
+ const updatedMetadata = {
160
+ ...metadata,
161
+ emitBranch: newEmitBranch,
162
+ }
163
+
164
+ yield* Effect.tryPromise({
165
+ try: async () => {
166
+ const { writeAgencyMetadata } = await import("../types")
167
+ await writeAgencyMetadata(gitRoot, updatedMetadata)
168
+ },
169
+ catch: (error) => new Error(`Failed to update agency.json: ${error}`),
170
+ })
171
+
172
+ // Stage and commit the change
173
+ yield* git.gitAdd(["agency.json"], gitRoot)
174
+ // Format: chore: agency rebase (baseBranch) sourceBranch → emitBranch
175
+ const commitMessage = `chore: agency rebase (${baseBranch}) ${currentBranch} → ${newEmitBranch}`
176
+ yield* git.gitCommit(commitMessage, gitRoot, { noVerify: true })
177
+
178
+ log(info(`Updated emit branch to ${highlight.branch(newEmitBranch)}`))
179
+ }
180
+
181
+ // Inform user about next steps
182
+ log(
183
+ info(
184
+ `Your source branch has been rebased. You may want to:\n` +
185
+ ` - Run 'agency emit' to regenerate your emit branch\n` +
186
+ ` - Run 'agency push --force' to update the remote branch (use with caution)`,
187
+ ),
188
+ )
189
+ })
190
+
191
+ export const help = `
192
+ Usage: agency rebase [options]
193
+
194
+ Rebase the current agency source branch onto the latest base branch (typically origin/main).
195
+
196
+ This command is useful when:
197
+ - Your work has been merged to the main branch via PR
198
+ - You want to continue working on the same branch with a clean history
199
+ - You want to incorporate the latest changes from main into your branch
200
+
201
+ Behavior:
202
+ - Ensures you're on an agency source branch (has agency.json)
203
+ - Checks for uncommitted changes (rebase requires a clean working directory)
204
+ - Fetches the latest from the base branch if it's a remote branch
205
+ - Rebases your current branch onto the base branch
206
+ - All agency files (TASK.md, AGENTS.md, opencode.json, etc.) are preserved
207
+ - Your emit branch may need to be regenerated with 'agency emit'
208
+
209
+ Base Branch Selection:
210
+ The command determines the base branch in this order:
211
+ 1. Explicitly provided base-branch argument
212
+ 2. Branch-specific base branch from agency.json (set by 'agency task')
213
+ 3. Repository-level default base branch from .git/config
214
+ 4. Auto-detected from origin/HEAD or common branches (origin/main, etc.)
215
+
216
+ Arguments:
217
+ [base-branch] Optional base branch to rebase onto
218
+ (defaults to saved base branch or origin/main)
219
+
220
+ Options:
221
+ --emit <name> Set a new emit branch name in agency.json after rebasing
222
+ --branch <name> (Deprecated: use --emit) Set a new emit branch name
223
+
224
+ Examples:
225
+ agency rebase # Rebase onto saved base branch
226
+ agency rebase origin/main # Rebase onto origin/main explicitly
227
+ agency rebase --emit new-branch # Rebase and set new emit branch name
228
+
229
+ Workflow:
230
+ 1. User works on agency/feature-A branch
231
+ 2. User runs 'agency emit' and 'agency push' to create a PR
232
+ 3. PR gets merged into main
233
+ 4. User runs 'agency rebase' to rebase agency/feature-A onto origin/main
234
+ 5. User continues working on agency/feature-A with updated main branch
235
+ 6. User runs 'agency emit' again to create a fresh emit branch
236
+
237
+ Notes:
238
+ - This command only works on agency source branches (with agency.json)
239
+ - If conflicts occur during rebase, you must resolve them manually
240
+ - After rebasing, your emit branch will be outdated - run 'agency emit' to regenerate it
241
+ - Be careful when force-pushing after a rebase - coordinate with your team
242
+ - This command is different from 'agency task --continue' which creates a new branch
243
+ `
@@ -46,7 +46,7 @@ describe("save command", () => {
46
46
 
47
47
  // Initialize with template
48
48
  await initAgency(tempDir, "test-save")
49
- await task({ silent: true, branch: "test-feature" })
49
+ await task({ silent: true, emit: "test-feature" })
50
50
 
51
51
  // Modify the files
52
52
  await Bun.write(join(tempDir, "AGENTS.md"), "# Modified content")
@@ -71,7 +71,7 @@ describe("save command", () => {
71
71
  await initAgency(tempDir, "test-no-files")
72
72
  await task({
73
73
  silent: true,
74
- branch: "test-feature",
74
+ emit: "test-feature",
75
75
  })
76
76
 
77
77
  await expect(
@@ -104,7 +104,7 @@ describe("save command", () => {
104
104
  await initAgency(tempDir, "test-partial")
105
105
  await task({
106
106
  silent: true,
107
- branch: "test-feature",
107
+ emit: "test-feature",
108
108
  })
109
109
 
110
110
  // Remove AGENTS.md (just to test skipping behavior)
@@ -137,7 +137,7 @@ describe("save command", () => {
137
137
 
138
138
  // Initialize with template
139
139
  await initAgency(tempDir, "test-dir")
140
- await task({ silent: true, branch: "test-feature" })
140
+ await task({ silent: true, emit: "test-feature" })
141
141
 
142
142
  // Create a directory with files
143
143
  const docsDir = join(tempDir, "docs")
@@ -166,7 +166,7 @@ describe("save command", () => {
166
166
 
167
167
  // Initialize with template
168
168
  await initAgency(tempDir, "test-task")
169
- await task({ silent: true, branch: "test-feature" })
169
+ await task({ silent: true, emit: "test-feature" })
170
170
 
171
171
  // Create a TASK.md with the {task} placeholder
172
172
  await Bun.write(join(tempDir, "TASK.md"), "{task}\n\n## Notes")
@@ -185,7 +185,7 @@ describe("save command", () => {
185
185
  await initAgency(tempDir, "test-task-invalid")
186
186
  await task({
187
187
  silent: true,
188
- branch: "test-feature",
188
+ emit: "test-feature",
189
189
  })
190
190
 
191
191
  // Create a TASK.md without the {task} placeholder
@@ -208,7 +208,7 @@ describe("save command", () => {
208
208
  await initAgency(tempDir, "test-task-subdir")
209
209
  await task({
210
210
  silent: true,
211
- branch: "test-feature",
211
+ emit: "test-feature",
212
212
  })
213
213
 
214
214
  // Create a TASK.md in a subdirectory with the {task} placeholder
@@ -229,7 +229,7 @@ describe("save command", () => {
229
229
  await initAgency(tempDir, "test-task-subdir-invalid")
230
230
  await task({
231
231
  silent: true,
232
- branch: "test-feature",
232
+ emit: "test-feature",
233
233
  })
234
234
 
235
235
  // Create a TASK.md in a subdirectory without the {task} placeholder