@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.
- package/README.md +15 -4
- package/cli.ts +35 -22
- package/package.json +1 -1
- package/src/commands/emit.test.ts +1 -1
- package/src/commands/emit.ts +16 -5
- package/src/commands/push.test.ts +1 -1
- package/src/commands/push.ts +8 -5
- package/src/commands/rebase.test.ts +521 -0
- package/src/commands/rebase.ts +243 -0
- package/src/commands/save.test.ts +8 -8
- package/src/commands/task-branching.test.ts +312 -13
- package/src/commands/task-continue.test.ts +311 -0
- package/src/commands/task-edit.test.ts +4 -4
- package/src/commands/task-main.test.ts +57 -32
- package/src/commands/task.ts +371 -79
- package/src/services/AgencyMetadataService.ts +9 -1
- package/src/services/GitService.ts +61 -1
- package/src/utils/glob.test.ts +154 -0
- package/src/utils/glob.ts +78 -0
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
232
|
+
emit: "test-feature",
|
|
233
233
|
})
|
|
234
234
|
|
|
235
235
|
// Create a TASK.md in a subdirectory without the {task} placeholder
|