@markjaquith/agency 1.7.0 → 1.8.1
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/cli.ts +3 -0
- package/package.json +1 -1
- package/src/commands/emit.ts +1 -1
- package/src/commands/task-squash.test.ts +266 -0
- package/src/commands/task.ts +160 -3
package/cli.ts
CHANGED
|
@@ -30,6 +30,7 @@ import { TemplateService } from "./src/services/TemplateService"
|
|
|
30
30
|
import { OpencodeService } from "./src/services/OpencodeService"
|
|
31
31
|
import { ClaudeService } from "./src/services/ClaudeService"
|
|
32
32
|
import { FilterRepoService } from "./src/services/FilterRepoService"
|
|
33
|
+
import { FormatterService } from "./src/services/FormatterService"
|
|
33
34
|
|
|
34
35
|
// Create CLI layer with all services
|
|
35
36
|
const CliLayer = Layer.mergeAll(
|
|
@@ -41,6 +42,7 @@ const CliLayer = Layer.mergeAll(
|
|
|
41
42
|
OpencodeService.Default,
|
|
42
43
|
ClaudeService.Default,
|
|
43
44
|
FilterRepoService.Default,
|
|
45
|
+
FormatterService.Default,
|
|
44
46
|
)
|
|
45
47
|
|
|
46
48
|
/**
|
|
@@ -334,6 +336,7 @@ const commands: Record<string, Command> = {
|
|
|
334
336
|
from: options.from,
|
|
335
337
|
fromCurrent: options["from-current"],
|
|
336
338
|
continue: options.continue,
|
|
339
|
+
squash: options.squash,
|
|
337
340
|
}),
|
|
338
341
|
)
|
|
339
342
|
},
|
package/package.json
CHANGED
package/src/commands/emit.ts
CHANGED
|
@@ -43,7 +43,7 @@ export const emit = (options: EmitOptions = {}) =>
|
|
|
43
43
|
yield* withBranchProtection(gitRoot, emitCore(gitRoot, options))
|
|
44
44
|
})
|
|
45
45
|
|
|
46
|
-
const emitCore = (gitRoot: string, options: EmitOptions) =>
|
|
46
|
+
export const emitCore = (gitRoot: string, options: EmitOptions) =>
|
|
47
47
|
Effect.gen(function* () {
|
|
48
48
|
const { force = false, verbose = false, skipFilter = false } = options
|
|
49
49
|
const { log, verboseLog } = createLoggers(options)
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { test, expect, describe, beforeEach, afterEach } from "bun:test"
|
|
2
|
+
import { join } from "path"
|
|
3
|
+
import { task } from "./task"
|
|
4
|
+
import {
|
|
5
|
+
createTempDir,
|
|
6
|
+
cleanupTempDir,
|
|
7
|
+
initGitRepo,
|
|
8
|
+
initAgency,
|
|
9
|
+
fileExists,
|
|
10
|
+
readFile,
|
|
11
|
+
runTestEffect,
|
|
12
|
+
getCurrentBranch,
|
|
13
|
+
getGitOutput,
|
|
14
|
+
} from "../test-utils"
|
|
15
|
+
|
|
16
|
+
describe("task --continue --squash", () => {
|
|
17
|
+
let tempDir: string
|
|
18
|
+
let originalCwd: string
|
|
19
|
+
let originalConfigDir: string | undefined
|
|
20
|
+
|
|
21
|
+
beforeEach(async () => {
|
|
22
|
+
tempDir = await createTempDir()
|
|
23
|
+
originalCwd = process.cwd()
|
|
24
|
+
originalConfigDir = process.env.AGENCY_CONFIG_DIR
|
|
25
|
+
process.env.AGENCY_CONFIG_DIR = await createTempDir()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
afterEach(async () => {
|
|
29
|
+
process.chdir(originalCwd)
|
|
30
|
+
if (originalConfigDir !== undefined) {
|
|
31
|
+
process.env.AGENCY_CONFIG_DIR = originalConfigDir
|
|
32
|
+
} else {
|
|
33
|
+
delete process.env.AGENCY_CONFIG_DIR
|
|
34
|
+
}
|
|
35
|
+
if (
|
|
36
|
+
process.env.AGENCY_CONFIG_DIR &&
|
|
37
|
+
process.env.AGENCY_CONFIG_DIR !== originalConfigDir
|
|
38
|
+
) {
|
|
39
|
+
await cleanupTempDir(process.env.AGENCY_CONFIG_DIR)
|
|
40
|
+
}
|
|
41
|
+
await cleanupTempDir(tempDir)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test("fails when --squash is used without --continue", async () => {
|
|
45
|
+
await initGitRepo(tempDir)
|
|
46
|
+
process.chdir(tempDir)
|
|
47
|
+
|
|
48
|
+
await initAgency(tempDir, "test")
|
|
49
|
+
|
|
50
|
+
await expect(
|
|
51
|
+
runTestEffect(task({ silent: true, squash: true, emit: "some-branch" })),
|
|
52
|
+
).rejects.toThrow("--squash flag can only be used with --continue")
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test("squashes emitted commits into a single commit on new branch", async () => {
|
|
56
|
+
await initGitRepo(tempDir)
|
|
57
|
+
process.chdir(tempDir)
|
|
58
|
+
|
|
59
|
+
await initAgency(tempDir, "test")
|
|
60
|
+
|
|
61
|
+
// Create a task branch with agency files
|
|
62
|
+
await runTestEffect(
|
|
63
|
+
task({ silent: true, emit: "feature-v1", task: "Original task" }),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
// Add two code commits to the branch
|
|
67
|
+
await Bun.write(join(tempDir, "feature-a.txt"), "feature A content")
|
|
68
|
+
await Bun.spawn(["git", "add", "feature-a.txt"], {
|
|
69
|
+
cwd: tempDir,
|
|
70
|
+
stdout: "pipe",
|
|
71
|
+
stderr: "pipe",
|
|
72
|
+
}).exited
|
|
73
|
+
await Bun.spawn(["git", "commit", "--no-verify", "-m", "Add feature A"], {
|
|
74
|
+
cwd: tempDir,
|
|
75
|
+
stdout: "pipe",
|
|
76
|
+
stderr: "pipe",
|
|
77
|
+
}).exited
|
|
78
|
+
|
|
79
|
+
await Bun.write(join(tempDir, "feature-b.txt"), "feature B content")
|
|
80
|
+
await Bun.spawn(["git", "add", "feature-b.txt"], {
|
|
81
|
+
cwd: tempDir,
|
|
82
|
+
stdout: "pipe",
|
|
83
|
+
stderr: "pipe",
|
|
84
|
+
}).exited
|
|
85
|
+
await Bun.spawn(["git", "commit", "--no-verify", "-m", "Add feature B"], {
|
|
86
|
+
cwd: tempDir,
|
|
87
|
+
stdout: "pipe",
|
|
88
|
+
stderr: "pipe",
|
|
89
|
+
}).exited
|
|
90
|
+
|
|
91
|
+
// Now continue with squash
|
|
92
|
+
await runTestEffect(
|
|
93
|
+
task({
|
|
94
|
+
silent: true,
|
|
95
|
+
continue: true,
|
|
96
|
+
squash: true,
|
|
97
|
+
emit: "feature-v2",
|
|
98
|
+
task: "New task for v2",
|
|
99
|
+
}),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
// Verify we're on the new branch
|
|
103
|
+
const newBranch = await getCurrentBranch(tempDir)
|
|
104
|
+
expect(newBranch).toBe("agency--feature-v2")
|
|
105
|
+
|
|
106
|
+
// Verify both feature files exist (squash brought them in)
|
|
107
|
+
expect(await fileExists(join(tempDir, "feature-a.txt"))).toBe(true)
|
|
108
|
+
expect(await fileExists(join(tempDir, "feature-b.txt"))).toBe(true)
|
|
109
|
+
|
|
110
|
+
// Verify agency files exist
|
|
111
|
+
expect(await fileExists(join(tempDir, "agency.json"))).toBe(true)
|
|
112
|
+
expect(await fileExists(join(tempDir, "TASK.md"))).toBe(true)
|
|
113
|
+
expect(await fileExists(join(tempDir, "AGENCY.md"))).toBe(true)
|
|
114
|
+
|
|
115
|
+
// Verify TASK.md has FRESH content (not the old "Original task")
|
|
116
|
+
const taskContent = await readFile(join(tempDir, "TASK.md"))
|
|
117
|
+
expect(taskContent).toContain("New task for v2")
|
|
118
|
+
expect(taskContent).not.toContain("Original task")
|
|
119
|
+
|
|
120
|
+
// Verify agency.json has updated emitBranch
|
|
121
|
+
const agencyJson = JSON.parse(await readFile(join(tempDir, "agency.json")))
|
|
122
|
+
expect(agencyJson.emitBranch).toBe("feature-v2")
|
|
123
|
+
|
|
124
|
+
// Verify commit history: should be initial commit + squash commit + agency files commit
|
|
125
|
+
// (not the individual "Add feature A" and "Add feature B" commits)
|
|
126
|
+
const logOutput = await getGitOutput(tempDir, [
|
|
127
|
+
"log",
|
|
128
|
+
"--oneline",
|
|
129
|
+
"--format=%s",
|
|
130
|
+
])
|
|
131
|
+
const commits = logOutput.trim().split("\n")
|
|
132
|
+
|
|
133
|
+
// Should NOT contain individual feature commits
|
|
134
|
+
expect(logOutput).not.toContain("Add feature A")
|
|
135
|
+
expect(logOutput).not.toContain("Add feature B")
|
|
136
|
+
|
|
137
|
+
// Should contain a squash commit
|
|
138
|
+
expect(logOutput).toContain("squash: prior work from feature-v1")
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test("creates fresh TASK.md with default placeholder when no task provided in silent mode", async () => {
|
|
142
|
+
await initGitRepo(tempDir)
|
|
143
|
+
process.chdir(tempDir)
|
|
144
|
+
|
|
145
|
+
await initAgency(tempDir, "test")
|
|
146
|
+
|
|
147
|
+
// Create a task branch
|
|
148
|
+
await runTestEffect(
|
|
149
|
+
task({ silent: true, emit: "feature-v1", task: "Old task" }),
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
// Add a code commit
|
|
153
|
+
await Bun.write(join(tempDir, "code.txt"), "code content")
|
|
154
|
+
await Bun.spawn(["git", "add", "code.txt"], {
|
|
155
|
+
cwd: tempDir,
|
|
156
|
+
stdout: "pipe",
|
|
157
|
+
stderr: "pipe",
|
|
158
|
+
}).exited
|
|
159
|
+
await Bun.spawn(["git", "commit", "--no-verify", "-m", "Add code"], {
|
|
160
|
+
cwd: tempDir,
|
|
161
|
+
stdout: "pipe",
|
|
162
|
+
stderr: "pipe",
|
|
163
|
+
}).exited
|
|
164
|
+
|
|
165
|
+
// Continue with squash but no --task option (silent mode)
|
|
166
|
+
await runTestEffect(
|
|
167
|
+
task({
|
|
168
|
+
silent: true,
|
|
169
|
+
continue: true,
|
|
170
|
+
squash: true,
|
|
171
|
+
emit: "feature-v2",
|
|
172
|
+
}),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
// TASK.md should have the default placeholder, not the old task
|
|
176
|
+
const taskContent = await readFile(join(tempDir, "TASK.md"))
|
|
177
|
+
expect(taskContent).toContain("{task}")
|
|
178
|
+
expect(taskContent).not.toContain("Old task")
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test("handles empty squash when old branch has no code commits", async () => {
|
|
182
|
+
await initGitRepo(tempDir)
|
|
183
|
+
process.chdir(tempDir)
|
|
184
|
+
|
|
185
|
+
await initAgency(tempDir, "test")
|
|
186
|
+
|
|
187
|
+
// Create a task branch WITHOUT any code commits
|
|
188
|
+
await runTestEffect(
|
|
189
|
+
task({ silent: true, emit: "feature-v1", task: "Just agency files" }),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
// Continue with squash - should succeed even with no code changes
|
|
193
|
+
await runTestEffect(
|
|
194
|
+
task({
|
|
195
|
+
silent: true,
|
|
196
|
+
continue: true,
|
|
197
|
+
squash: true,
|
|
198
|
+
emit: "feature-v2",
|
|
199
|
+
task: "New task",
|
|
200
|
+
}),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
// Verify we're on the new branch
|
|
204
|
+
const newBranch = await getCurrentBranch(tempDir)
|
|
205
|
+
expect(newBranch).toBe("agency--feature-v2")
|
|
206
|
+
|
|
207
|
+
// Verify agency files exist
|
|
208
|
+
expect(await fileExists(join(tempDir, "agency.json"))).toBe(true)
|
|
209
|
+
expect(await fileExists(join(tempDir, "TASK.md"))).toBe(true)
|
|
210
|
+
|
|
211
|
+
// Verify fresh TASK.md
|
|
212
|
+
const taskContent = await readFile(join(tempDir, "TASK.md"))
|
|
213
|
+
expect(taskContent).toContain("New task")
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
test("preserves other agency files while creating fresh TASK.md", async () => {
|
|
217
|
+
await initGitRepo(tempDir)
|
|
218
|
+
process.chdir(tempDir)
|
|
219
|
+
|
|
220
|
+
await initAgency(tempDir, "test")
|
|
221
|
+
|
|
222
|
+
// Create a task branch
|
|
223
|
+
await runTestEffect(
|
|
224
|
+
task({ silent: true, emit: "feature-v1", task: "Old task" }),
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
// Read the original AGENCY.md content
|
|
228
|
+
const originalAgencyMd = await readFile(join(tempDir, "AGENCY.md"))
|
|
229
|
+
|
|
230
|
+
// Add a code commit
|
|
231
|
+
await Bun.write(join(tempDir, "code.txt"), "code")
|
|
232
|
+
await Bun.spawn(["git", "add", "code.txt"], {
|
|
233
|
+
cwd: tempDir,
|
|
234
|
+
stdout: "pipe",
|
|
235
|
+
stderr: "pipe",
|
|
236
|
+
}).exited
|
|
237
|
+
await Bun.spawn(["git", "commit", "--no-verify", "-m", "Add code"], {
|
|
238
|
+
cwd: tempDir,
|
|
239
|
+
stdout: "pipe",
|
|
240
|
+
stderr: "pipe",
|
|
241
|
+
}).exited
|
|
242
|
+
|
|
243
|
+
// Continue with squash
|
|
244
|
+
await runTestEffect(
|
|
245
|
+
task({
|
|
246
|
+
silent: true,
|
|
247
|
+
continue: true,
|
|
248
|
+
squash: true,
|
|
249
|
+
emit: "feature-v2",
|
|
250
|
+
task: "New task",
|
|
251
|
+
}),
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
// AGENCY.md should be carried forward (not fresh)
|
|
255
|
+
const newAgencyMd = await readFile(join(tempDir, "AGENCY.md"))
|
|
256
|
+
expect(newAgencyMd).toBe(originalAgencyMd)
|
|
257
|
+
|
|
258
|
+
// TASK.md should be fresh
|
|
259
|
+
const taskContent = await readFile(join(tempDir, "TASK.md"))
|
|
260
|
+
expect(taskContent).toContain("New task")
|
|
261
|
+
expect(taskContent).not.toContain("Old task")
|
|
262
|
+
|
|
263
|
+
// opencode.json should be carried forward
|
|
264
|
+
expect(await fileExists(join(tempDir, "opencode.json"))).toBe(true)
|
|
265
|
+
})
|
|
266
|
+
})
|
package/src/commands/task.ts
CHANGED
|
@@ -21,6 +21,8 @@ import {
|
|
|
21
21
|
} from "../utils/pr-branch"
|
|
22
22
|
import { getTopLevelDir, dirToGlobPattern } from "../utils/glob"
|
|
23
23
|
import { AGENCY_REMOVE_COMMIT } from "../constants"
|
|
24
|
+
import { emitCore } from "./emit"
|
|
25
|
+
import { withBranchProtection } from "../utils/effect"
|
|
24
26
|
|
|
25
27
|
interface TaskOptions extends BaseCommandOptions {
|
|
26
28
|
path?: string
|
|
@@ -30,6 +32,7 @@ interface TaskOptions extends BaseCommandOptions {
|
|
|
30
32
|
from?: string
|
|
31
33
|
fromCurrent?: boolean
|
|
32
34
|
continue?: boolean
|
|
35
|
+
squash?: boolean
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
interface TaskEditOptions extends BaseCommandOptions {}
|
|
@@ -37,10 +40,13 @@ interface TaskEditOptions extends BaseCommandOptions {}
|
|
|
37
40
|
/**
|
|
38
41
|
* Continue a task by creating a new branch with the same agency files.
|
|
39
42
|
* This is useful after a PR is merged and you want to continue working on the task.
|
|
43
|
+
*
|
|
44
|
+
* With --squash: emits the old branch, squash-merges the emitted commits into the new
|
|
45
|
+
* branch as a single commit, and creates a fresh TASK.md with a new task description.
|
|
40
46
|
*/
|
|
41
47
|
const taskContinue = (options: TaskOptions) =>
|
|
42
48
|
Effect.gen(function* () {
|
|
43
|
-
const { silent = false } = options
|
|
49
|
+
const { silent = false, squash = false } = options
|
|
44
50
|
const { log, verboseLog } = createLoggers(options)
|
|
45
51
|
|
|
46
52
|
const git = yield* GitService
|
|
@@ -128,6 +134,11 @@ const taskContinue = (options: TaskOptions) =>
|
|
|
128
134
|
// Read all the existing files content before switching branches
|
|
129
135
|
const fileContents = new Map<string, string>()
|
|
130
136
|
for (const file of filesToCopy) {
|
|
137
|
+
// When squashing, skip reading TASK.md — we'll generate a fresh one
|
|
138
|
+
if (squash && file === "TASK.md") {
|
|
139
|
+
verboseLog("Skipping TASK.md read (will create fresh for --squash)")
|
|
140
|
+
continue
|
|
141
|
+
}
|
|
131
142
|
const filePath = resolve(targetPath, file)
|
|
132
143
|
const exists = yield* fs.exists(filePath)
|
|
133
144
|
if (exists) {
|
|
@@ -139,6 +150,26 @@ const taskContinue = (options: TaskOptions) =>
|
|
|
139
150
|
}
|
|
140
151
|
}
|
|
141
152
|
|
|
153
|
+
// When squashing, prompt for fresh task description before switching branches
|
|
154
|
+
let freshTaskDescription: string | undefined
|
|
155
|
+
if (squash) {
|
|
156
|
+
if (options.task) {
|
|
157
|
+
freshTaskDescription = options.task
|
|
158
|
+
verboseLog(`Using task from option: ${freshTaskDescription}`)
|
|
159
|
+
} else if (!silent) {
|
|
160
|
+
freshTaskDescription = yield* promptService.prompt(
|
|
161
|
+
"New task description: ",
|
|
162
|
+
)
|
|
163
|
+
if (!freshTaskDescription) {
|
|
164
|
+
log(
|
|
165
|
+
info(
|
|
166
|
+
"Skipping task description (TASK.md will use default placeholder)",
|
|
167
|
+
),
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
142
173
|
// Prompt for new branch name if not provided
|
|
143
174
|
let branchName = options.emit || options.branch
|
|
144
175
|
if (!branchName) {
|
|
@@ -174,6 +205,43 @@ const taskContinue = (options: TaskOptions) =>
|
|
|
174
205
|
)
|
|
175
206
|
}
|
|
176
207
|
|
|
208
|
+
// When squashing, emit the old source branch first (before switching away)
|
|
209
|
+
// This creates a clean emit branch with backpack files stripped out
|
|
210
|
+
let oldEmitBranchName: string | undefined
|
|
211
|
+
if (squash) {
|
|
212
|
+
const cleanFromCurrent = extractCleanBranch(
|
|
213
|
+
currentBranch,
|
|
214
|
+
config.sourceBranchPattern,
|
|
215
|
+
)
|
|
216
|
+
oldEmitBranchName = cleanFromCurrent
|
|
217
|
+
? makeEmitBranchName(cleanFromCurrent, config.emitBranch)
|
|
218
|
+
: existingMetadata.emitBranch || undefined
|
|
219
|
+
|
|
220
|
+
if (!oldEmitBranchName) {
|
|
221
|
+
return yield* Effect.fail(
|
|
222
|
+
new Error(
|
|
223
|
+
`Could not determine emit branch name for ${highlight.branch(currentBranch)}.\n` +
|
|
224
|
+
`Ensure agency.json has an emitBranch field or the branch follows the source pattern.`,
|
|
225
|
+
),
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
verboseLog(
|
|
230
|
+
`Emitting ${highlight.branch(currentBranch)} → ${highlight.branch(oldEmitBranchName)}`,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
yield* withBranchProtection(
|
|
234
|
+
targetPath,
|
|
235
|
+
emitCore(targetPath, {
|
|
236
|
+
silent: true,
|
|
237
|
+
verbose: options.verbose,
|
|
238
|
+
emit: oldEmitBranchName,
|
|
239
|
+
}),
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
log(done(`Emitted ${highlight.branch(oldEmitBranchName)} for squash`))
|
|
243
|
+
}
|
|
244
|
+
|
|
177
245
|
// Determine base branch to branch from
|
|
178
246
|
let baseBranchToBranchFrom: string | undefined
|
|
179
247
|
|
|
@@ -227,10 +295,70 @@ const taskContinue = (options: TaskOptions) =>
|
|
|
227
295
|
)
|
|
228
296
|
log(done(`Created and switched to ${highlight.branch(sourceBranchName)}`))
|
|
229
297
|
|
|
298
|
+
// When squashing, merge the emitted commits as a single squash commit
|
|
299
|
+
if (squash && oldEmitBranchName) {
|
|
300
|
+
verboseLog(
|
|
301
|
+
`Squash-merging ${highlight.branch(oldEmitBranchName)} into ${highlight.branch(sourceBranchName)}`,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
// Squash merge the emitted (clean) branch
|
|
305
|
+
const mergeResult = yield* git
|
|
306
|
+
.merge(targetPath, oldEmitBranchName, { squash: true })
|
|
307
|
+
.pipe(
|
|
308
|
+
Effect.catchAll((err) => {
|
|
309
|
+
return Effect.fail(
|
|
310
|
+
new Error(
|
|
311
|
+
`Squash merge of ${oldEmitBranchName} failed.\n` +
|
|
312
|
+
`This typically means there are conflicts with the base branch.\n` +
|
|
313
|
+
`Try rebasing the old branch first with 'agency rebase', then retry.\n` +
|
|
314
|
+
`Details: ${err}`,
|
|
315
|
+
),
|
|
316
|
+
)
|
|
317
|
+
}),
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
// Check if the squash merge produced any staged changes
|
|
321
|
+
const statusResult = yield* git
|
|
322
|
+
.runGitCommand(["git", "diff", "--cached", "--quiet"], targetPath)
|
|
323
|
+
.pipe(
|
|
324
|
+
Effect.catchAll(() =>
|
|
325
|
+
Effect.succeed({ exitCode: 1, stdout: "", stderr: "" }),
|
|
326
|
+
),
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
if (statusResult.exitCode === 0) {
|
|
330
|
+
// No changes — the old branch had no code commits (only agency files)
|
|
331
|
+
verboseLog(
|
|
332
|
+
"Squash merge produced no changes (old branch had no code commits), skipping squash commit",
|
|
333
|
+
)
|
|
334
|
+
} else {
|
|
335
|
+
// There are staged changes — commit them
|
|
336
|
+
yield* git.gitCommit(
|
|
337
|
+
`squash: prior work from ${oldEmitBranchName}`,
|
|
338
|
+
targetPath,
|
|
339
|
+
{ noVerify: true },
|
|
340
|
+
)
|
|
341
|
+
log(
|
|
342
|
+
done(
|
|
343
|
+
`Squashed prior work from ${highlight.branch(oldEmitBranchName)}`,
|
|
344
|
+
),
|
|
345
|
+
)
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
230
349
|
// Calculate the new emit branch name
|
|
231
350
|
const newEmitBranchName = makeEmitBranchName(branchName, config.emitBranch)
|
|
232
351
|
verboseLog(`New emit branch name: ${newEmitBranchName}`)
|
|
233
352
|
|
|
353
|
+
// When squashing, generate fresh TASK.md content
|
|
354
|
+
if (squash) {
|
|
355
|
+
const taskTemplate = `{task}\n\n## Tasks\n\n- [ ] Populate this list with tasks\n`
|
|
356
|
+
const taskContent = freshTaskDescription
|
|
357
|
+
? taskTemplate.replace("{task}", freshTaskDescription)
|
|
358
|
+
: taskTemplate
|
|
359
|
+
fileContents.set("TASK.md", taskContent)
|
|
360
|
+
}
|
|
361
|
+
|
|
234
362
|
// Write all the files to the new branch
|
|
235
363
|
const createdFiles: string[] = []
|
|
236
364
|
|
|
@@ -287,7 +415,10 @@ const taskContinue = (options: TaskOptions) =>
|
|
|
287
415
|
yield* Effect.gen(function* () {
|
|
288
416
|
yield* git.gitAdd(createdFiles, targetPath)
|
|
289
417
|
// Format: chore: agency task --continue (baseBranch) originalSource => newSource => newEmit
|
|
290
|
-
const
|
|
418
|
+
const continueFlag = squash
|
|
419
|
+
? "agency task --continue --squash"
|
|
420
|
+
: "agency task --continue"
|
|
421
|
+
const commitMessage = `chore: ${continueFlag} (${baseBranchToBranchFrom}) ${currentBranch} → ${sourceBranchName} → ${newEmitBranchName}`
|
|
291
422
|
yield* git.gitCommit(commitMessage, targetPath, {
|
|
292
423
|
noVerify: true,
|
|
293
424
|
})
|
|
@@ -304,7 +435,9 @@ const taskContinue = (options: TaskOptions) =>
|
|
|
304
435
|
|
|
305
436
|
log(
|
|
306
437
|
info(
|
|
307
|
-
|
|
438
|
+
squash
|
|
439
|
+
? `Continued task with squash and ${createdFiles.length} file${plural(createdFiles.length)} from ${highlight.branch(currentBranch)}`
|
|
440
|
+
: `Continued task with ${createdFiles.length} file${plural(createdFiles.length)} from ${highlight.branch(currentBranch)}`,
|
|
308
441
|
),
|
|
309
442
|
)
|
|
310
443
|
})
|
|
@@ -314,6 +447,13 @@ export const task = (options: TaskOptions = {}) =>
|
|
|
314
447
|
const { silent = false, verbose = false } = options
|
|
315
448
|
const { log, verboseLog } = createLoggers(options)
|
|
316
449
|
|
|
450
|
+
// Validate --squash requires --continue
|
|
451
|
+
if (options.squash && !options.continue) {
|
|
452
|
+
return yield* Effect.fail(
|
|
453
|
+
new Error("The --squash flag can only be used with --continue."),
|
|
454
|
+
)
|
|
455
|
+
}
|
|
456
|
+
|
|
317
457
|
// Handle --continue flag
|
|
318
458
|
if (options.continue) {
|
|
319
459
|
return yield* taskContinue(options)
|
|
@@ -1104,6 +1244,8 @@ Options:
|
|
|
1104
1244
|
--from <branch> Branch to branch from instead of main upstream branch
|
|
1105
1245
|
--from-current Initialize on current branch instead of creating a new one
|
|
1106
1246
|
--continue Continue a task by copying agency files to a new branch
|
|
1247
|
+
--squash With --continue: squash the old branch's emitted commits into one
|
|
1248
|
+
--task <desc> Task description for TASK.md (avoids interactive prompt)
|
|
1107
1249
|
|
|
1108
1250
|
Continue Mode (--continue):
|
|
1109
1251
|
After a PR is merged, use '--continue' to create a new branch that preserves
|
|
@@ -1117,6 +1259,20 @@ Continue Mode (--continue):
|
|
|
1117
1259
|
3. A new branch is created from main with all your agency files
|
|
1118
1260
|
4. The emitBranch in agency.json is updated for the new branch
|
|
1119
1261
|
|
|
1262
|
+
Squash Mode (--continue --squash):
|
|
1263
|
+
Combines --continue with squashing the old branch's emitted commits into a
|
|
1264
|
+
single commit on the new branch. This is useful when you want to carry forward
|
|
1265
|
+
code changes but collapse the commit history. A fresh TASK.md is created with
|
|
1266
|
+
a new task description.
|
|
1267
|
+
|
|
1268
|
+
The squash workflow:
|
|
1269
|
+
1. Be on an agency source branch with agency files
|
|
1270
|
+
2. Run 'agency task --continue --squash <new-branch-name>'
|
|
1271
|
+
3. The old branch is emitted (backpack files stripped)
|
|
1272
|
+
4. A new branch is created from main
|
|
1273
|
+
5. The emitted commits are squash-merged as a single commit
|
|
1274
|
+
6. Fresh agency files (including new TASK.md) are added
|
|
1275
|
+
|
|
1120
1276
|
Base Branch Selection:
|
|
1121
1277
|
By default, 'agency task' fetches from the remote and branches from the latest
|
|
1122
1278
|
main upstream branch (e.g., origin/main). You can override this behavior with:
|
|
@@ -1133,6 +1289,7 @@ Examples:
|
|
|
1133
1289
|
agency task my-feature --from develop # Create 'my-feature' from 'develop'
|
|
1134
1290
|
agency task --from-current # Initialize on current branch (no new branch)
|
|
1135
1291
|
agency task --continue my-feature-v2 # Continue task on new branch after PR merge
|
|
1292
|
+
agency task --continue --squash v2 # Continue with squashed code from old branch
|
|
1136
1293
|
|
|
1137
1294
|
Template Workflow:
|
|
1138
1295
|
1. Run 'agency init' to select template (saved to .git/config)
|