Skip to content

Commit b518212

Browse files
committed
feat: Make checkpoint on new task
Ensures that invoking the `newTaskTool` always creates a checkpoint, even if no files have changed. This provides a consistent state snapshot before a sub-task is initiated.
1 parent d149d65 commit b518212

File tree

5 files changed

+193
-7
lines changed

5 files changed

+193
-7
lines changed

src/core/checkpoints/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ async function getInitializedCheckpointService(
152152
}
153153
}
154154

155-
export async function checkpointSave(cline: Task) {
155+
export async function checkpointSave(cline: Task, force = false) {
156156
const service = getCheckpointService(cline)
157157

158158
if (!service) {
@@ -169,7 +169,7 @@ export async function checkpointSave(cline: Task) {
169169
telemetryService.captureCheckpointCreated(cline.taskId)
170170

171171
// Start the checkpoint process in the background.
172-
return service.saveCheckpoint(`Task: ${cline.taskId}, Time: ${Date.now()}`).catch((err) => {
172+
return service.saveCheckpoint(`Task: ${cline.taskId}, Time: ${Date.now()}`, { allowEmpty: force }).catch((err) => {
173173
console.error("[Cline#checkpointSave] caught unexpected error, disabling checkpoints", err)
174174
cline.enableCheckpoints = false
175175
})

src/core/task/Task.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1713,8 +1713,8 @@ export class Task extends EventEmitter<ClineEvents> {
17131713

17141714
// Checkpoints
17151715

1716-
public async checkpointSave() {
1717-
return checkpointSave(this)
1716+
public async checkpointSave(force: boolean = false) {
1717+
return checkpointSave(this, force)
17181718
}
17191719

17201720
public async checkpointRestore(options: CheckpointRestoreOptions) {

src/core/tools/newTaskTool.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,11 @@ export async function newTaskTool(
6969
return
7070
}
7171

72+
if (cline.enableCheckpoints) {
73+
cline.checkpointSave(true)
74+
await delay(350)
75+
}
76+
7277
// Preserve the current mode so we can resume with it later.
7378
cline.pausedModeSlug = (await provider.getState()).mode ?? defaultModeSlug
7479

src/services/checkpoints/ShadowCheckpointService.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -214,17 +214,23 @@ export abstract class ShadowCheckpointService extends EventEmitter {
214214
return this.shadowGitConfigWorktree
215215
}
216216

217-
public async saveCheckpoint(message: string): Promise<CheckpointResult | undefined> {
217+
public async saveCheckpoint(
218+
message: string,
219+
options?: { allowEmpty?: boolean },
220+
): Promise<CheckpointResult | undefined> {
218221
try {
219-
this.log(`[${this.constructor.name}#saveCheckpoint] starting checkpoint save`)
222+
this.log(
223+
`[${this.constructor.name}#saveCheckpoint] starting checkpoint save (allowEmpty: ${options?.allowEmpty ?? false})`,
224+
)
220225

221226
if (!this.git) {
222227
throw new Error("Shadow git repo not initialized")
223228
}
224229

225230
const startTime = Date.now()
226231
await this.stageAll(this.git)
227-
const result = await this.git.commit(message)
232+
const commitArgs = options?.allowEmpty ? { "--allow-empty": null } : undefined
233+
const result = await this.git.commit(message, commitArgs)
228234
const isFirst = this._checkpoints.length === 0
229235
const fromHash = this._checkpoints[this._checkpoints.length - 1] ?? this.baseHash!
230236
const toHash = result.commit || fromHash

src/services/checkpoints/__tests__/ShadowCheckpointService.test.ts

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -632,5 +632,180 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])(
632632
expect(checkpointHandler).not.toHaveBeenCalled()
633633
})
634634
})
635+
636+
describe(`${klass.name}#saveCheckpoint with allowEmpty option`, () => {
637+
it("creates checkpoint with allowEmpty=true even when no changes", async () => {
638+
// No changes made, but force checkpoint creation
639+
const result = await service.saveCheckpoint("Empty checkpoint", { allowEmpty: true })
640+
641+
expect(result).toBeDefined()
642+
expect(result?.commit).toBeTruthy()
643+
expect(typeof result?.commit).toBe("string")
644+
})
645+
646+
it("does not create checkpoint with allowEmpty=false when no changes", async () => {
647+
const result = await service.saveCheckpoint("No changes checkpoint", { allowEmpty: false })
648+
649+
expect(result).toBeUndefined()
650+
})
651+
652+
it("does not create checkpoint by default when no changes", async () => {
653+
const result = await service.saveCheckpoint("Default behavior checkpoint")
654+
655+
expect(result).toBeUndefined()
656+
})
657+
658+
it("creates checkpoint with changes regardless of allowEmpty setting", async () => {
659+
await fs.writeFile(testFile, "Modified content for allowEmpty test")
660+
661+
const resultWithAllowEmpty = await service.saveCheckpoint("With changes and allowEmpty", { allowEmpty: true })
662+
expect(resultWithAllowEmpty?.commit).toBeTruthy()
663+
664+
await fs.writeFile(testFile, "Another modification for allowEmpty test")
665+
666+
const resultWithoutAllowEmpty = await service.saveCheckpoint("With changes, no allowEmpty")
667+
expect(resultWithoutAllowEmpty?.commit).toBeTruthy()
668+
})
669+
670+
it("emits checkpoint event for empty commits when allowEmpty=true", async () => {
671+
const checkpointHandler = jest.fn()
672+
service.on("checkpoint", checkpointHandler)
673+
674+
const result = await service.saveCheckpoint("Empty checkpoint event test", { allowEmpty: true })
675+
676+
expect(checkpointHandler).toHaveBeenCalledTimes(1)
677+
const eventData = checkpointHandler.mock.calls[0][0]
678+
expect(eventData.type).toBe("checkpoint")
679+
expect(eventData.toHash).toBe(result?.commit)
680+
expect(typeof eventData.duration).toBe("number")
681+
expect(typeof eventData.isFirst).toBe("boolean") // Can be true or false depending on checkpoint history
682+
})
683+
684+
it("does not emit checkpoint event when no changes and allowEmpty=false", async () => {
685+
// First, create a checkpoint to ensure we're not in the initial state
686+
await fs.writeFile(testFile, "Setup content")
687+
await service.saveCheckpoint("Setup checkpoint")
688+
689+
// Reset the file to original state
690+
await fs.writeFile(testFile, "Hello, world!")
691+
await service.saveCheckpoint("Reset to original")
692+
693+
// Now test with no changes and allowEmpty=false
694+
const checkpointHandler = jest.fn()
695+
service.on("checkpoint", checkpointHandler)
696+
697+
const result = await service.saveCheckpoint("No changes, no event", { allowEmpty: false })
698+
699+
expect(result).toBeUndefined()
700+
expect(checkpointHandler).not.toHaveBeenCalled()
701+
})
702+
703+
it("handles multiple empty checkpoints correctly", async () => {
704+
const commit1 = await service.saveCheckpoint("First empty checkpoint", { allowEmpty: true })
705+
expect(commit1?.commit).toBeTruthy()
706+
707+
const commit2 = await service.saveCheckpoint("Second empty checkpoint", { allowEmpty: true })
708+
expect(commit2?.commit).toBeTruthy()
709+
710+
// Commits should be different
711+
expect(commit1?.commit).not.toBe(commit2?.commit)
712+
})
713+
714+
it("logs correct message for allowEmpty option", async () => {
715+
const logMessages: string[] = []
716+
const testService = await klass.create({
717+
taskId: "log-test",
718+
shadowDir: path.join(tmpDir, `log-test-${Date.now()}`),
719+
workspaceDir: service.workspaceDir,
720+
log: (message: string) => logMessages.push(message),
721+
})
722+
await testService.initShadowGit()
723+
724+
await testService.saveCheckpoint("Test logging with allowEmpty", { allowEmpty: true })
725+
726+
const saveCheckpointLogs = logMessages.filter(msg =>
727+
msg.includes("starting checkpoint save") && msg.includes("allowEmpty: true")
728+
)
729+
expect(saveCheckpointLogs).toHaveLength(1)
730+
731+
await testService.saveCheckpoint("Test logging without allowEmpty")
732+
733+
const defaultLogs = logMessages.filter(msg =>
734+
msg.includes("starting checkpoint save") && msg.includes("allowEmpty: false")
735+
)
736+
expect(defaultLogs).toHaveLength(1)
737+
})
738+
739+
it("maintains checkpoint history with empty commits", async () => {
740+
// Create a regular checkpoint
741+
await fs.writeFile(testFile, "Regular change")
742+
const regularCommit = await service.saveCheckpoint("Regular checkpoint")
743+
expect(regularCommit?.commit).toBeTruthy()
744+
745+
// Create an empty checkpoint
746+
const emptyCommit = await service.saveCheckpoint("Empty checkpoint", { allowEmpty: true })
747+
expect(emptyCommit?.commit).toBeTruthy()
748+
749+
// Create another regular checkpoint
750+
await fs.writeFile(testFile, "Another regular change")
751+
const anotherCommit = await service.saveCheckpoint("Another regular checkpoint")
752+
expect(anotherCommit?.commit).toBeTruthy()
753+
754+
// Verify we can restore to the empty checkpoint
755+
await service.restoreCheckpoint(emptyCommit!.commit)
756+
expect(await fs.readFile(testFile, "utf-8")).toBe("Regular change")
757+
758+
// Verify we can restore to other checkpoints
759+
await service.restoreCheckpoint(regularCommit!.commit)
760+
expect(await fs.readFile(testFile, "utf-8")).toBe("Regular change")
761+
762+
await service.restoreCheckpoint(anotherCommit!.commit)
763+
expect(await fs.readFile(testFile, "utf-8")).toBe("Another regular change")
764+
})
765+
766+
it("handles getDiff correctly with empty commits", async () => {
767+
// Create a regular checkpoint
768+
await fs.writeFile(testFile, "Content before empty")
769+
const beforeEmpty = await service.saveCheckpoint("Before empty")
770+
expect(beforeEmpty?.commit).toBeTruthy()
771+
772+
// Create an empty checkpoint
773+
const emptyCommit = await service.saveCheckpoint("Empty checkpoint", { allowEmpty: true })
774+
expect(emptyCommit?.commit).toBeTruthy()
775+
776+
// Get diff between regular commit and empty commit
777+
const diff = await service.getDiff({
778+
from: beforeEmpty!.commit,
779+
to: emptyCommit!.commit
780+
})
781+
782+
// Should have no differences since empty commit doesn't change anything
783+
expect(diff).toHaveLength(0)
784+
})
785+
786+
it("works correctly in integration with new task workflow", async () => {
787+
// Simulate the new task workflow where we force a checkpoint even with no changes
788+
// This tests the specific use case mentioned in the git commit
789+
790+
// Start with a clean state (no pending changes)
791+
const initialState = await service.saveCheckpoint("Check initial state")
792+
expect(initialState).toBeUndefined() // No changes, so no commit
793+
794+
// Force a checkpoint for new task (this is the new functionality)
795+
const newTaskCheckpoint = await service.saveCheckpoint("New task checkpoint", { allowEmpty: true })
796+
expect(newTaskCheckpoint?.commit).toBeTruthy()
797+
798+
// Verify the checkpoint was created and can be restored
799+
await fs.writeFile(testFile, "Work done in new task")
800+
const workCommit = await service.saveCheckpoint("Work in new task")
801+
expect(workCommit?.commit).toBeTruthy()
802+
803+
// Restore to the new task checkpoint
804+
await service.restoreCheckpoint(newTaskCheckpoint!.commit)
805+
806+
// File should be back to original state
807+
expect(await fs.readFile(testFile, "utf-8")).toBe("Hello, world!")
808+
})
809+
})
635810
},
636811
)

0 commit comments

Comments
 (0)