// npx vitest run src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts import fs from "fs/promises" import path from "path" import os from "os" import { EventEmitter } from "events" import { simpleGit, SimpleGit } from "simple-git" import { fileExistsAtPath } from "../../../utils/fs" import * as fileSearch from "../../../services/search/file-search" import { RepoPerTaskCheckpointService } from "../RepoPerTaskCheckpointService" const tmpDir = path.join(os.tmpdir(), "CheckpointService") const initWorkspaceRepo = async ({ workspaceDir, userName = "Roo Code", userEmail = "support@roocode.com", testFileName = "test.txt", textFileContent = "Hello, world!", }: { workspaceDir: string userName?: string userEmail?: string testFileName?: string textFileContent?: string }) => { // Create a temporary directory for testing. await fs.mkdir(workspaceDir, { recursive: true }) // Initialize git repo. const git = simpleGit(workspaceDir) await git.init() await git.addConfig("user.name", userName) await git.addConfig("user.email", userEmail) // Create test file. const testFile = path.join(workspaceDir, testFileName) await fs.writeFile(testFile, textFileContent) // Create initial commit. await git.add(".") await git.commit("Initial commit")! return { git, testFile } } describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])( "CheckpointService", (klass, prefix) => { const taskId = "test-task" let workspaceGit: SimpleGit let testFile: string let service: RepoPerTaskCheckpointService beforeEach(async () => { const shadowDir = path.join(tmpDir, `${prefix}-${Date.now()}`) const workspaceDir = path.join(tmpDir, `workspace-${Date.now()}`) const repo = await initWorkspaceRepo({ workspaceDir }) workspaceGit = repo.git testFile = repo.testFile service = await klass.create({ taskId, shadowDir, workspaceDir, log: () => {} }) await service.initShadowGit() }) afterEach(async () => { vitest.restoreAllMocks() }) afterAll(async () => { await fs.rm(tmpDir, { recursive: true, force: true }) }, 60_000) // 60 second timeout for Windows cleanup describe(`${klass.name}#getDiff`, () => { it("returns the correct diff between commits", async () => { await fs.writeFile(testFile, "Ahoy, world!") const commit1 = await service.saveCheckpoint("Ahoy, world!") expect(commit1?.commit).toBeTruthy() await fs.writeFile(testFile, "Goodbye, world!") const commit2 = await service.saveCheckpoint("Goodbye, world!") expect(commit2?.commit).toBeTruthy() const diff1 = await service.getDiff({ to: commit1!.commit }) expect(diff1).toHaveLength(1) expect(diff1[0].paths.relative).toBe("test.txt") expect(diff1[0].paths.absolute).toBe(testFile) expect(diff1[0].content.before).toBe("Hello, world!") expect(diff1[0].content.after).toBe("Ahoy, world!") const diff2 = await service.getDiff({ from: service.baseHash, to: commit2!.commit }) expect(diff2).toHaveLength(1) expect(diff2[0].paths.relative).toBe("test.txt") expect(diff2[0].paths.absolute).toBe(testFile) expect(diff2[0].content.before).toBe("Hello, world!") expect(diff2[0].content.after).toBe("Goodbye, world!") const diff12 = await service.getDiff({ from: commit1!.commit, to: commit2!.commit }) expect(diff12).toHaveLength(1) expect(diff12[0].paths.relative).toBe("test.txt") expect(diff12[0].paths.absolute).toBe(testFile) expect(diff12[0].content.before).toBe("Ahoy, world!") expect(diff12[0].content.after).toBe("Goodbye, world!") }) it("handles new files in diff", async () => { const newFile = path.join(service.workspaceDir, "new.txt") await fs.writeFile(newFile, "New file content") const commit = await service.saveCheckpoint("Add new file") expect(commit?.commit).toBeTruthy() const changes = await service.getDiff({ to: commit!.commit }) const change = changes.find((c) => c.paths.relative === "new.txt") expect(change).toBeDefined() expect(change?.content.before).toBe("") expect(change?.content.after).toBe("New file content") }) it("handles deleted files in diff", async () => { const fileToDelete = path.join(service.workspaceDir, "new.txt") await fs.writeFile(fileToDelete, "New file content") const commit1 = await service.saveCheckpoint("Add file") expect(commit1?.commit).toBeTruthy() await fs.unlink(fileToDelete) const commit2 = await service.saveCheckpoint("Delete file") expect(commit2?.commit).toBeTruthy() const changes = await service.getDiff({ from: commit1!.commit, to: commit2!.commit }) const change = changes.find((c) => c.paths.relative === "new.txt") expect(change).toBeDefined() expect(change!.content.before).toBe("New file content") expect(change!.content.after).toBe("") }) }) describe(`${klass.name}#saveCheckpoint`, () => { it("creates a checkpoint if there are pending changes", async () => { await fs.writeFile(testFile, "Ahoy, world!") const commit1 = await service.saveCheckpoint("First checkpoint") expect(commit1?.commit).toBeTruthy() const details1 = await service.getDiff({ to: commit1!.commit }) expect(details1[0].content.before).toContain("Hello, world!") expect(details1[0].content.after).toContain("Ahoy, world!") await fs.writeFile(testFile, "Hola, world!") const commit2 = await service.saveCheckpoint("Second checkpoint") expect(commit2?.commit).toBeTruthy() const details2 = await service.getDiff({ from: commit1!.commit, to: commit2!.commit }) expect(details2[0].content.before).toContain("Ahoy, world!") expect(details2[0].content.after).toContain("Hola, world!") // Switch to checkpoint 1. await service.restoreCheckpoint(commit1!.commit) expect(await fs.readFile(testFile, "utf-8")).toBe("Ahoy, world!") // Switch to checkpoint 2. await service.restoreCheckpoint(commit2!.commit) expect(await fs.readFile(testFile, "utf-8")).toBe("Hola, world!") // Switch back to initial commit. expect(service.baseHash).toBeTruthy() await service.restoreCheckpoint(service.baseHash!) expect(await fs.readFile(testFile, "utf-8")).toBe("Hello, world!") }) it("preserves workspace and index state after saving checkpoint", async () => { // Create three files with different states: staged, unstaged, and mixed. const unstagedFile = path.join(service.workspaceDir, "unstaged.txt") const stagedFile = path.join(service.workspaceDir, "staged.txt") const mixedFile = path.join(service.workspaceDir, "mixed.txt") await fs.writeFile(unstagedFile, "Initial unstaged") await fs.writeFile(stagedFile, "Initial staged") await fs.writeFile(mixedFile, "Initial mixed") await workspaceGit.add(["."]) const result = await workspaceGit.commit("Add initial files") expect(result?.commit).toBeTruthy() await fs.writeFile(unstagedFile, "Modified unstaged") await fs.writeFile(stagedFile, "Modified staged") await workspaceGit.add([stagedFile]) await fs.writeFile(mixedFile, "Modified mixed - staged") await workspaceGit.add([mixedFile]) await fs.writeFile(mixedFile, "Modified mixed - unstaged") // Save checkpoint. const commit = await service.saveCheckpoint("Test checkpoint") expect(commit?.commit).toBeTruthy() // Verify workspace state is preserved. const status = await workspaceGit.status() // All files should be modified. expect(status.modified).toContain("unstaged.txt") expect(status.modified).toContain("staged.txt") expect(status.modified).toContain("mixed.txt") // Only staged and mixed files should be staged. expect(status.staged).not.toContain("unstaged.txt") expect(status.staged).toContain("staged.txt") expect(status.staged).toContain("mixed.txt") // Verify file contents. expect(await fs.readFile(unstagedFile, "utf-8")).toBe("Modified unstaged") expect(await fs.readFile(stagedFile, "utf-8")).toBe("Modified staged") expect(await fs.readFile(mixedFile, "utf-8")).toBe("Modified mixed - unstaged") // Verify staged changes (--cached shows only staged changes). const stagedDiff = await workspaceGit.diff(["--cached", "mixed.txt"]) expect(stagedDiff).toContain("-Initial mixed") expect(stagedDiff).toContain("+Modified mixed - staged") // Verify unstaged changes (shows working directory changes). const unstagedDiff = await workspaceGit.diff(["mixed.txt"]) expect(unstagedDiff).toContain("-Modified mixed - staged") expect(unstagedDiff).toContain("+Modified mixed - unstaged") }) it("does not create a checkpoint if there are no pending changes", async () => { const commit0 = await service.saveCheckpoint("Zeroth checkpoint") expect(commit0?.commit).toBeFalsy() await fs.writeFile(testFile, "Ahoy, world!") const commit1 = await service.saveCheckpoint("First checkpoint") expect(commit1?.commit).toBeTruthy() const commit2 = await service.saveCheckpoint("Second checkpoint") expect(commit2?.commit).toBeFalsy() }) it("includes untracked files in checkpoints", async () => { // Create an untracked file. const untrackedFile = path.join(service.workspaceDir, "untracked.txt") await fs.writeFile(untrackedFile, "I am untracked!") // Save a checkpoint with the untracked file. const commit1 = await service.saveCheckpoint("Checkpoint with untracked file") expect(commit1?.commit).toBeTruthy() // Verify the untracked file was included in the checkpoint. const details = await service.getDiff({ to: commit1!.commit }) expect(details[0].content.before).toContain("") expect(details[0].content.after).toContain("I am untracked!") // Create another checkpoint with a different state. await fs.writeFile(testFile, "Changed tracked file") const commit2 = await service.saveCheckpoint("Second checkpoint") expect(commit2?.commit).toBeTruthy() // Restore first checkpoint and verify untracked file is preserved. await service.restoreCheckpoint(commit1!.commit) expect(await fs.readFile(untrackedFile, "utf-8")).toBe("I am untracked!") expect(await fs.readFile(testFile, "utf-8")).toBe("Hello, world!") // Restore second checkpoint and verify untracked file remains (since // restore preserves untracked files) await service.restoreCheckpoint(commit2!.commit) expect(await fs.readFile(untrackedFile, "utf-8")).toBe("I am untracked!") expect(await fs.readFile(testFile, "utf-8")).toBe("Changed tracked file") }) it("handles file deletions correctly", async () => { await fs.writeFile(testFile, "I am tracked!") const untrackedFile = path.join(service.workspaceDir, "new.txt") await fs.writeFile(untrackedFile, "I am untracked!") const commit1 = await service.saveCheckpoint("First checkpoint") expect(commit1?.commit).toBeTruthy() await fs.unlink(testFile) await fs.unlink(untrackedFile) const commit2 = await service.saveCheckpoint("Second checkpoint") expect(commit2?.commit).toBeTruthy() // Verify files are gone. await expect(fs.readFile(testFile, "utf-8")).rejects.toThrow() await expect(fs.readFile(untrackedFile, "utf-8")).rejects.toThrow() // Restore first checkpoint. await service.restoreCheckpoint(commit1!.commit) expect(await fs.readFile(testFile, "utf-8")).toBe("I am tracked!") expect(await fs.readFile(untrackedFile, "utf-8")).toBe("I am untracked!") // Restore second checkpoint. await service.restoreCheckpoint(commit2!.commit) await expect(fs.readFile(testFile, "utf-8")).rejects.toThrow() await expect(fs.readFile(untrackedFile, "utf-8")).rejects.toThrow() }) it("does not create a checkpoint for ignored files", async () => { // Create a file that matches an ignored pattern (e.g., .log file). const ignoredFile = path.join(service.workspaceDir, "ignored.log") await fs.writeFile(ignoredFile, "Initial ignored content") const commit = await service.saveCheckpoint("Ignored file checkpoint") expect(commit?.commit).toBeFalsy() await fs.writeFile(ignoredFile, "Modified ignored content") const commit2 = await service.saveCheckpoint("Ignored file modified checkpoint") expect(commit2?.commit).toBeFalsy() expect(await fs.readFile(ignoredFile, "utf-8")).toBe("Modified ignored content") }) it("does not create a checkpoint for LFS files", async () => { // Create a .gitattributes file with LFS patterns. const gitattributesPath = path.join(service.workspaceDir, ".gitattributes") await fs.writeFile(gitattributesPath, "*.lfs filter=lfs diff=lfs merge=lfs -text") // Re-initialize the service to trigger a write to .git/info/exclude. service = new klass(service.taskId, service.checkpointsDir, service.workspaceDir, () => {}) const excludesPath = path.join(service.checkpointsDir, ".git", "info", "exclude") expect((await fs.readFile(excludesPath, "utf-8")).split("\n")).not.toContain("*.lfs") await service.initShadowGit() expect((await fs.readFile(excludesPath, "utf-8")).split("\n")).toContain("*.lfs") const commit0 = await service.saveCheckpoint("Add gitattributes") expect(commit0?.commit).toBeTruthy() // Create a file that matches an LFS pattern. const lfsFile = path.join(service.workspaceDir, "foo.lfs") await fs.writeFile(lfsFile, "Binary file content simulation") const commit = await service.saveCheckpoint("LFS file checkpoint") expect(commit?.commit).toBeFalsy() await fs.writeFile(lfsFile, "Modified binary content") const commit2 = await service.saveCheckpoint("LFS file modified checkpoint") expect(commit2?.commit).toBeFalsy() expect(await fs.readFile(lfsFile, "utf-8")).toBe("Modified binary content") }) }) describe(`${klass.name}#create`, () => { it("initializes a git repository if one does not already exist", async () => { const shadowDir = path.join(tmpDir, `${prefix}2-${Date.now()}`) const workspaceDir = path.join(tmpDir, `workspace2-${Date.now()}`) await fs.mkdir(workspaceDir) const newTestFile = path.join(workspaceDir, "test.txt") await fs.writeFile(newTestFile, "Hello, world!") expect(await fs.readFile(newTestFile, "utf-8")).toBe("Hello, world!") // Ensure the git repository was initialized. const newService = await klass.create({ taskId, shadowDir, workspaceDir, log: () => {} }) const { created } = await newService.initShadowGit() expect(created).toBeTruthy() const gitDir = path.join(newService.checkpointsDir, ".git") expect(await fs.stat(gitDir)).toBeTruthy() // Save a new checkpoint: Ahoy, world! await fs.writeFile(newTestFile, "Ahoy, world!") const commit1 = await newService.saveCheckpoint("Ahoy, world!") expect(commit1?.commit).toBeTruthy() expect(await fs.readFile(newTestFile, "utf-8")).toBe("Ahoy, world!") // Restore "Hello, world!" await newService.restoreCheckpoint(newService.baseHash!) expect(await fs.readFile(newTestFile, "utf-8")).toBe("Hello, world!") // Restore "Ahoy, world!" await newService.restoreCheckpoint(commit1!.commit) expect(await fs.readFile(newTestFile, "utf-8")).toBe("Ahoy, world!") await fs.rm(newService.checkpointsDir, { recursive: true, force: true }) await fs.rm(newService.workspaceDir, { recursive: true, force: true }) }) }) describe(`${klass.name}#hasNestedGitRepositories`, () => { it("throws error when nested git repositories are detected during initialization", async () => { // Create a new temporary workspace and service for this test. const shadowDir = path.join(tmpDir, `${prefix}-nested-git-${Date.now()}`) const workspaceDir = path.join(tmpDir, `workspace-nested-git-${Date.now()}`) // Create a primary workspace repo. await fs.mkdir(workspaceDir, { recursive: true }) const mainGit = simpleGit(workspaceDir) await mainGit.init() await mainGit.addConfig("user.name", "Roo Code") await mainGit.addConfig("user.email", "support@roocode.com") // Create a nested repo inside the workspace. const nestedRepoPath = path.join(workspaceDir, "nested-project") await fs.mkdir(nestedRepoPath, { recursive: true }) const nestedGit = simpleGit(nestedRepoPath) await nestedGit.init() await nestedGit.addConfig("user.name", "Roo Code") await nestedGit.addConfig("user.email", "support@roocode.com") // Add a file to the nested repo. const nestedFile = path.join(nestedRepoPath, "nested-file.txt") await fs.writeFile(nestedFile, "Content in nested repo") await nestedGit.add(".") await nestedGit.commit("Initial commit in nested repo") // Create a test file in the main workspace. const mainFile = path.join(workspaceDir, "main-file.txt") await fs.writeFile(mainFile, "Content in main repo") await mainGit.add(".") await mainGit.commit("Initial commit in main repo") // Confirm nested git directory exists before initialization. const nestedGitDir = path.join(nestedRepoPath, ".git") const headFile = path.join(nestedGitDir, "HEAD") await fs.writeFile(headFile, "HEAD") expect(await fileExistsAtPath(nestedGitDir)).toBe(true) vitest.spyOn(fileSearch, "executeRipgrep").mockImplementation(({ args }) => { const searchPattern = args[4] if (searchPattern.includes(".git/HEAD")) { // Return the HEAD file path, not the .git directory const headFilePath = path.join(path.relative(workspaceDir, nestedGitDir), "HEAD") return Promise.resolve([ { path: headFilePath, type: "file", // HEAD is a file, not a folder label: "HEAD", }, ]) } else { return Promise.resolve([]) } }) const service = new klass(taskId, shadowDir, workspaceDir, () => {}) // Verify that initialization throws an error when nested git repos are detected // The error message now includes the specific path of the nested repository await expect(service.initShadowGit()).rejects.toThrowError( /Checkpoints are disabled because a nested git repository was detected at:/, ) // Clean up. vitest.restoreAllMocks() await fs.rm(shadowDir, { recursive: true, force: true }) await fs.rm(workspaceDir, { recursive: true, force: true }) }) it("succeeds when no nested git repositories are detected", async () => { // Create a new temporary workspace and service for this test. const shadowDir = path.join(tmpDir, `${prefix}-no-nested-git-${Date.now()}`) const workspaceDir = path.join(tmpDir, `workspace-no-nested-git-${Date.now()}`) // Create a primary workspace repo without any nested repos. await fs.mkdir(workspaceDir, { recursive: true }) const mainGit = simpleGit(workspaceDir) await mainGit.init() await mainGit.addConfig("user.name", "Roo Code") await mainGit.addConfig("user.email", "support@roocode.com") // Create a test file in the main workspace. const mainFile = path.join(workspaceDir, "main-file.txt") await fs.writeFile(mainFile, "Content in main repo") await mainGit.add(".") await mainGit.commit("Initial commit in main repo") vitest.spyOn(fileSearch, "executeRipgrep").mockImplementation(() => { // Return empty array to simulate no nested git repos found return Promise.resolve([]) }) const service = new klass(taskId, shadowDir, workspaceDir, () => {}) // Verify that initialization succeeds when no nested git repos are detected await expect(service.initShadowGit()).resolves.not.toThrow() expect(service.isInitialized).toBe(true) // Clean up. vitest.restoreAllMocks() await fs.rm(shadowDir, { recursive: true, force: true }) await fs.rm(workspaceDir, { recursive: true, force: true }) }) it("succeeds when workspace has no root .git but has sibling git repos in subdirectories", async () => { // This test covers the issue #10636 scenario where a user opens a parent folder // that contains multiple sibling projects (e.g., frontend and backend), each with // their own independent git repositories. // Create a new temporary workspace WITHOUT a root .git directory. const shadowDir = path.join(tmpDir, `${prefix}-sibling-git-${Date.now()}`) const workspaceDir = path.join(tmpDir, `workspace-sibling-git-${Date.now()}`) await fs.mkdir(workspaceDir, { recursive: true }) // Create sibling git repos inside the workspace (simulating frontend/backend structure) const frontendDir = path.join(workspaceDir, "frontend") await fs.mkdir(frontendDir, { recursive: true }) const frontendGit = simpleGit(frontendDir) await frontendGit.init() await frontendGit.addConfig("user.name", "Roo Code") await frontendGit.addConfig("user.email", "support@roocode.com") const frontendFile = path.join(frontendDir, "index.ts") await fs.writeFile(frontendFile, "console.log('frontend');") await frontendGit.add(".") await frontendGit.commit("Initial frontend commit") const backendDir = path.join(workspaceDir, "backend") await fs.mkdir(backendDir, { recursive: true }) const backendGit = simpleGit(backendDir) await backendGit.init() await backendGit.addConfig("user.name", "Roo Code") await backendGit.addConfig("user.email", "support@roocode.com") const backendFile = path.join(backendDir, "server.ts") await fs.writeFile(backendFile, "console.log('backend');") await backendGit.add(".") await backendGit.commit("Initial backend commit") // Note: We do NOT create a .git directory at the workspace root // This simulates the user's scenario of opening the parent folder // Use klass.create() like other tests do const service = await klass.create({ taskId, shadowDir, workspaceDir, log: () => {} }) // Verify that initialization succeeds because there's no root .git // The sibling git repos should NOT be considered "nested" await expect(service.initShadowGit()).resolves.not.toThrow() expect(service.isInitialized).toBe(true) expect(service.baseHash).toBeTruthy() // Verify we can create a checkpoint with a file in the root workspace // (not inside the sibling repos, to ensure tracking works) const rootFile = path.join(workspaceDir, "readme.txt") await fs.writeFile(rootFile, "Root workspace file") const commit = await service.saveCheckpoint("Add root file") expect(commit?.commit).toBeTruthy() // Verify we can restore the checkpoint await fs.writeFile(rootFile, "Modified root file") await service.restoreCheckpoint(commit!.commit) expect(await fs.readFile(rootFile, "utf-8")).toBe("Root workspace file") // Clean up. await fs.rm(shadowDir, { recursive: true, force: true }) await fs.rm(workspaceDir, { recursive: true, force: true }) }) }) describe(`${klass.name}#events`, () => { it("emits initialize event when service is created", async () => { const shadowDir = path.join(tmpDir, `${prefix}3-${Date.now()}`) const workspaceDir = path.join(tmpDir, `workspace3-${Date.now()}`) await fs.mkdir(workspaceDir, { recursive: true }) const newTestFile = path.join(workspaceDir, "test.txt") await fs.writeFile(newTestFile, "Testing events!") // Create a mock implementation of emit to track events. const emitSpy = vitest.spyOn(EventEmitter.prototype, "emit") // Create the service - this will trigger the initialize event. const newService = await klass.create({ taskId, shadowDir, workspaceDir, log: () => {} }) await newService.initShadowGit() // Find the initialize event in the emit calls. let initializeEvent = null for (let i = 0; i < emitSpy.mock.calls.length; i++) { const call = emitSpy.mock.calls[i] if (call[0] === "initialize") { initializeEvent = call[1] break } } // Restore the spy. emitSpy.mockRestore() // Verify the event was emitted with the correct data. expect(initializeEvent).not.toBeNull() expect(initializeEvent.type).toBe("initialize") expect(initializeEvent.workspaceDir).toBe(workspaceDir) expect(initializeEvent.baseHash).toBeTruthy() expect(typeof initializeEvent.created).toBe("boolean") expect(typeof initializeEvent.duration).toBe("number") // Verify the event was emitted with the correct data. expect(initializeEvent).not.toBeNull() expect(initializeEvent.type).toBe("initialize") expect(initializeEvent.workspaceDir).toBe(workspaceDir) expect(initializeEvent.baseHash).toBeTruthy() expect(typeof initializeEvent.created).toBe("boolean") expect(typeof initializeEvent.duration).toBe("number") // Clean up. await fs.rm(shadowDir, { recursive: true, force: true }) await fs.rm(workspaceDir, { recursive: true, force: true }) }) it("emits checkpoint event when saving checkpoint", async () => { const checkpointHandler = vitest.fn() service.on("checkpoint", checkpointHandler) await fs.writeFile(testFile, "Changed content for checkpoint event test") const result = await service.saveCheckpoint("Test checkpoint event") expect(result?.commit).toBeDefined() expect(checkpointHandler).toHaveBeenCalledTimes(1) const eventData = checkpointHandler.mock.calls[0][0] expect(eventData.type).toBe("checkpoint") expect(eventData.toHash).toBeDefined() expect(eventData.toHash).toBe(result!.commit) expect(typeof eventData.duration).toBe("number") }) it("emits restore event when restoring checkpoint", async () => { // First create a checkpoint to restore. await fs.writeFile(testFile, "Content for restore test") const commit = await service.saveCheckpoint("Checkpoint for restore test") expect(commit?.commit).toBeTruthy() // Change the file again. await fs.writeFile(testFile, "Changed after checkpoint") // Setup restore event listener. const restoreHandler = vitest.fn() service.on("restore", restoreHandler) // Restore the checkpoint. await service.restoreCheckpoint(commit!.commit) // Verify the event was emitted. expect(restoreHandler).toHaveBeenCalledTimes(1) const eventData = restoreHandler.mock.calls[0][0] expect(eventData.type).toBe("restore") expect(eventData.commitHash).toBe(commit!.commit) expect(typeof eventData.duration).toBe("number") // Verify the file was actually restored. expect(await fs.readFile(testFile, "utf-8")).toBe("Content for restore test") }) it("emits error event when an error occurs", async () => { const errorHandler = vitest.fn() service.on("error", errorHandler) // Force an error by providing an invalid commit hash. const invalidCommitHash = "invalid-commit-hash" // Try to restore an invalid checkpoint. try { await service.restoreCheckpoint(invalidCommitHash) } catch (error) { // Expected to throw, we're testing the event emission. } // Verify the error event was emitted. expect(errorHandler).toHaveBeenCalledTimes(1) const eventData = errorHandler.mock.calls[0][0] expect(eventData.type).toBe("error") expect(eventData.error).toBeInstanceOf(Error) }) it("supports multiple event listeners for the same event", async () => { const checkpointHandler1 = vitest.fn() const checkpointHandler2 = vitest.fn() service.on("checkpoint", checkpointHandler1) service.on("checkpoint", checkpointHandler2) await fs.writeFile(testFile, "Content for multiple listeners test") const result = await service.saveCheckpoint("Testing multiple listeners") // Verify both handlers were called with the same event data. expect(checkpointHandler1).toHaveBeenCalledTimes(1) expect(checkpointHandler2).toHaveBeenCalledTimes(1) const eventData1 = checkpointHandler1.mock.calls[0][0] const eventData2 = checkpointHandler2.mock.calls[0][0] expect(eventData1).toEqual(eventData2) expect(eventData1.type).toBe("checkpoint") expect(eventData1.toHash).toBe(result?.commit) }) it("allows removing event listeners", async () => { const checkpointHandler = vitest.fn() // Add the listener. service.on("checkpoint", checkpointHandler) // Make a change and save a checkpoint. await fs.writeFile(testFile, "Content for remove listener test - part 1") await service.saveCheckpoint("Testing listener - part 1") // Verify handler was called. expect(checkpointHandler).toHaveBeenCalledTimes(1) checkpointHandler.mockClear() // Remove the listener. service.off("checkpoint", checkpointHandler) // Make another change and save a checkpoint. await fs.writeFile(testFile, "Content for remove listener test - part 2") await service.saveCheckpoint("Testing listener - part 2") // Verify handler was not called after being removed. expect(checkpointHandler).not.toHaveBeenCalled() }) }) describe(`${klass.name}#saveCheckpoint with allowEmpty option`, () => { it("creates checkpoint with allowEmpty=true even when no changes", async () => { // No changes made, but force checkpoint creation const result = await service.saveCheckpoint("Empty checkpoint", { allowEmpty: true }) expect(result).toBeDefined() expect(result?.commit).toBeTruthy() expect(typeof result?.commit).toBe("string") }) it("does not create checkpoint with allowEmpty=false when no changes", async () => { const result = await service.saveCheckpoint("No changes checkpoint", { allowEmpty: false }) expect(result).toBeUndefined() }) it("does not create checkpoint by default when no changes", async () => { const result = await service.saveCheckpoint("Default behavior checkpoint") expect(result).toBeUndefined() }) it("creates checkpoint with changes regardless of allowEmpty setting", async () => { await fs.writeFile(testFile, "Modified content for allowEmpty test") const resultWithAllowEmpty = await service.saveCheckpoint("With changes and allowEmpty", { allowEmpty: true, }) expect(resultWithAllowEmpty?.commit).toBeTruthy() await fs.writeFile(testFile, "Another modification for allowEmpty test") const resultWithoutAllowEmpty = await service.saveCheckpoint("With changes, no allowEmpty") expect(resultWithoutAllowEmpty?.commit).toBeTruthy() }) it("emits checkpoint event for empty commits when allowEmpty=true", async () => { const checkpointHandler = vitest.fn() service.on("checkpoint", checkpointHandler) const result = await service.saveCheckpoint("Empty checkpoint event test", { allowEmpty: true }) expect(checkpointHandler).toHaveBeenCalledTimes(1) const eventData = checkpointHandler.mock.calls[0][0] expect(eventData.type).toBe("checkpoint") expect(eventData.toHash).toBe(result?.commit) expect(typeof eventData.duration).toBe("number") }) it("does not emit checkpoint event when no changes and allowEmpty=false", async () => { // First, create a checkpoint to ensure we're not in the initial state await fs.writeFile(testFile, "Setup content") await service.saveCheckpoint("Setup checkpoint") // Reset the file to original state await fs.writeFile(testFile, "Hello, world!") await service.saveCheckpoint("Reset to original") // Now test with no changes and allowEmpty=false const checkpointHandler = vitest.fn() service.on("checkpoint", checkpointHandler) const result = await service.saveCheckpoint("No changes, no event", { allowEmpty: false }) expect(result).toBeUndefined() expect(checkpointHandler).not.toHaveBeenCalled() }) it("handles multiple empty checkpoints correctly", async () => { const commit1 = await service.saveCheckpoint("First empty checkpoint", { allowEmpty: true }) expect(commit1?.commit).toBeTruthy() const commit2 = await service.saveCheckpoint("Second empty checkpoint", { allowEmpty: true }) expect(commit2?.commit).toBeTruthy() // Commits should be different expect(commit1?.commit).not.toBe(commit2?.commit) }) it("logs correct message for allowEmpty option", async () => { const logMessages: string[] = [] const testService = await klass.create({ taskId: "log-test", shadowDir: path.join(tmpDir, `log-test-${Date.now()}`), workspaceDir: service.workspaceDir, log: (message: string) => logMessages.push(message), }) await testService.initShadowGit() await testService.saveCheckpoint("Test logging with allowEmpty", { allowEmpty: true }) const saveCheckpointLogs = logMessages.filter( (msg) => msg.includes("starting checkpoint save") && msg.includes("allowEmpty: true"), ) expect(saveCheckpointLogs).toHaveLength(1) await testService.saveCheckpoint("Test logging without allowEmpty") const defaultLogs = logMessages.filter( (msg) => msg.includes("starting checkpoint save") && msg.includes("allowEmpty: false"), ) expect(defaultLogs).toHaveLength(1) }) it("maintains checkpoint history with empty commits", async () => { // Create a regular checkpoint await fs.writeFile(testFile, "Regular change") const regularCommit = await service.saveCheckpoint("Regular checkpoint") expect(regularCommit?.commit).toBeTruthy() // Create an empty checkpoint const emptyCommit = await service.saveCheckpoint("Empty checkpoint", { allowEmpty: true }) expect(emptyCommit?.commit).toBeTruthy() // Create another regular checkpoint await fs.writeFile(testFile, "Another regular change") const anotherCommit = await service.saveCheckpoint("Another regular checkpoint") expect(anotherCommit?.commit).toBeTruthy() // Verify we can restore to the empty checkpoint await service.restoreCheckpoint(emptyCommit!.commit) expect(await fs.readFile(testFile, "utf-8")).toBe("Regular change") // Verify we can restore to other checkpoints await service.restoreCheckpoint(regularCommit!.commit) expect(await fs.readFile(testFile, "utf-8")).toBe("Regular change") await service.restoreCheckpoint(anotherCommit!.commit) expect(await fs.readFile(testFile, "utf-8")).toBe("Another regular change") }) it("handles getDiff correctly with empty commits", async () => { // Create a regular checkpoint await fs.writeFile(testFile, "Content before empty") const beforeEmpty = await service.saveCheckpoint("Before empty") expect(beforeEmpty?.commit).toBeTruthy() // Create an empty checkpoint const emptyCommit = await service.saveCheckpoint("Empty checkpoint", { allowEmpty: true }) expect(emptyCommit?.commit).toBeTruthy() // Get diff between regular commit and empty commit const diff = await service.getDiff({ from: beforeEmpty!.commit, to: emptyCommit!.commit, }) // Should have no differences since empty commit doesn't change anything expect(diff).toHaveLength(0) }) it("works correctly in integration with new task workflow", async () => { // Simulate the new task workflow where we force a checkpoint even with no changes // This tests the specific use case mentioned in the git commit // Start with a clean state (no pending changes) const initialState = await service.saveCheckpoint("Check initial state") expect(initialState).toBeUndefined() // No changes, so no commit // Force a checkpoint for new task (this is the new functionality) const newTaskCheckpoint = await service.saveCheckpoint("New task checkpoint", { allowEmpty: true }) expect(newTaskCheckpoint?.commit).toBeTruthy() // Verify the checkpoint was created and can be restored await fs.writeFile(testFile, "Work done in new task") const workCommit = await service.saveCheckpoint("Work in new task") expect(workCommit?.commit).toBeTruthy() // Restore to the new task checkpoint await service.restoreCheckpoint(newTaskCheckpoint!.commit) // File should be back to original state expect(await fs.readFile(testFile, "utf-8")).toBe("Hello, world!") }) it("does not apply git templates when initializing shadow repo", async () => { // This test verifies that git init uses --template="" and GIT_TEMPLATE_DIR // is stripped, preventing system/user git hooks from leaking into the shadow repo. const templateDir = path.join(tmpDir, `git-template-${Date.now()}`) const hooksDir = path.join(templateDir, "hooks") await fs.mkdir(hooksDir, { recursive: true }) await fs.writeFile(path.join(hooksDir, "pre-commit"), "#!/bin/sh\nexit 1", { mode: 0o755 }) const testShadowDir = path.join(tmpDir, `shadow-template-test-${Date.now()}`) const testWorkspaceDir = path.join(tmpDir, `workspace-template-test-${Date.now()}`) await initWorkspaceRepo({ workspaceDir: testWorkspaceDir }) const originalTemplateDir = process.env.GIT_TEMPLATE_DIR process.env.GIT_TEMPLATE_DIR = templateDir try { const testService = await klass.create({ taskId: `test-template-${Date.now()}`, shadowDir: testShadowDir, workspaceDir: testWorkspaceDir, log: () => {}, }) await testService.initShadowGit() // Verify no hooks were copied from the template const shadowHooksDir = path.join(testShadowDir, ".git", "hooks") let hookFiles: string[] = [] try { hookFiles = await fs.readdir(shadowHooksDir) } catch { // hooks dir may not exist at all, which is fine } // The pre-commit hook from the template should NOT be present expect(hookFiles).not.toContain("pre-commit") } finally { if (originalTemplateDir !== undefined) { process.env.GIT_TEMPLATE_DIR = originalTemplateDir } else { delete process.env.GIT_TEMPLATE_DIR } await fs.rm(testShadowDir, { recursive: true, force: true }) await fs.rm(testWorkspaceDir, { recursive: true, force: true }) await fs.rm(templateDir, { recursive: true, force: true }) } }) it("isolates checkpoint operations from GIT_DIR environment variable", async () => { // This test verifies the fix for the issue where GIT_DIR environment variable // causes checkpoint commits to go to the wrong repository. // In the real-world Dev Container scenario, GIT_DIR is set BEFORE Roo starts, // so we need to set it BEFORE creating the checkpoint service. // Create a separate git directory to simulate GIT_DIR pointing elsewhere const externalGitDir = path.join(tmpDir, `external-git-${Date.now()}`) await fs.mkdir(externalGitDir, { recursive: true }) const externalGit = simpleGit(externalGitDir) await externalGit.init() await externalGit.addConfig("user.name", "External User") await externalGit.addConfig("user.email", "external@example.com") // Create and commit a file in the external repo const externalFile = path.join(externalGitDir, "external.txt") await fs.writeFile(externalFile, "External content") await externalGit.add(".") await externalGit.commit("External commit") // Store the original commit count in the external repo const externalLogBefore = await externalGit.log() const externalCommitCountBefore = externalLogBefore.total // Initialize the workspace repo BEFORE setting GIT_DIR // (In Dev Containers, the workspace repo already exists before GIT_DIR is set) const testShadowDir = path.join(tmpDir, `shadow-git-dir-test-${Date.now()}`) const testWorkspaceDir = path.join(tmpDir, `workspace-git-dir-test-${Date.now()}`) const testRepo = await initWorkspaceRepo({ workspaceDir: testWorkspaceDir }) // Set GIT_DIR to point to the external repository BEFORE creating the service // This simulates the Dev Container environment where GIT_DIR is already set const originalGitDir = process.env.GIT_DIR const externalDotGit = path.join(externalGitDir, ".git") process.env.GIT_DIR = externalDotGit try { // Create a new checkpoint service with GIT_DIR already set // This is the key difference - we're creating the service // while GIT_DIR is set, just like in a real Dev Container const testService = await klass.create({ taskId: `test-git-dir-${Date.now()}`, shadowDir: testShadowDir, workspaceDir: testWorkspaceDir, log: () => {}, }) await testService.initShadowGit() // Make a change in the workspace and save a checkpoint const testWorkspaceFile = path.join(testWorkspaceDir, "test.txt") await fs.writeFile(testWorkspaceFile, "Modified with GIT_DIR set") const commit = await testService.saveCheckpoint("Checkpoint with GIT_DIR set") expect(commit?.commit).toBeTruthy() // Verify the checkpoint was saved in the shadow repo, not the external repo // Temporarily clear GIT_DIR to check the external repo delete process.env.GIT_DIR const externalGitCheck = simpleGit(externalGitDir) const externalLogAfter = await externalGitCheck.log() const externalCommitCountAfter = externalLogAfter.total // Restore GIT_DIR process.env.GIT_DIR = externalDotGit // External repo should have the same number of commits (no new commits) expect(externalCommitCountAfter).toBe(externalCommitCountBefore) // Verify the checkpoint is accessible in the shadow repo const diff = await testService.getDiff({ to: commit!.commit }) expect(diff).toHaveLength(1) expect(diff[0].paths.relative).toBe("test.txt") expect(diff[0].content.after).toBe("Modified with GIT_DIR set") // Verify we can restore the checkpoint await fs.writeFile(testWorkspaceFile, "Another modification") await testService.restoreCheckpoint(commit!.commit) expect(await fs.readFile(testWorkspaceFile, "utf-8")).toBe("Modified with GIT_DIR set") } finally { // Restore original GIT_DIR if (originalGitDir !== undefined) { process.env.GIT_DIR = originalGitDir } else { delete process.env.GIT_DIR } // Clean up external git directory await fs.rm(externalGitDir, { recursive: true, force: true }) } }) }) }, ) describe("worktree path comparison", () => { it("accepts core.worktree with trailing newline from git output", async () => { const shadowDir = path.join(tmpDir, `worktree-trim-${Date.now()}`) const workspaceDir = path.join(tmpDir, `workspace-trim-${Date.now()}`) try { await fs.mkdir(workspaceDir, { recursive: true }) const mainGit = simpleGit(workspaceDir) await mainGit.init() await mainGit.addConfig("user.name", "Roo Code") await mainGit.addConfig("user.email", "support@roocode.com") await fs.writeFile(path.join(workspaceDir, "main.txt"), "main content") await mainGit.add("main.txt") await mainGit.commit("Initial commit") vitest.spyOn(fileSearch, "executeRipgrep").mockImplementation(() => { return Promise.resolve([]) }) // First init to create the shadow repo const service1 = new RepoPerTaskCheckpointService("trim-test", shadowDir, workspaceDir, () => {}) await service1.initShadowGit() // Second init with stubbed worktree returning a trailing newline const service2 = new RepoPerTaskCheckpointService("trim-test-2", shadowDir, workspaceDir, () => {}) vitest.spyOn(service2 as any, "getShadowGitConfigWorktree").mockResolvedValue(workspaceDir + "\n") await service2.initShadowGit() } finally { vitest.restoreAllMocks() await fs.rm(shadowDir, { recursive: true, force: true }) await fs.rm(workspaceDir, { recursive: true, force: true }) } }) it("throws when core.worktree is missing", async () => { const shadowDir = path.join(tmpDir, `worktree-missing-${Date.now()}`) const workspaceDir = path.join(tmpDir, `workspace-missing-${Date.now()}`) try { await fs.mkdir(workspaceDir, { recursive: true }) const mainGit = simpleGit(workspaceDir) await mainGit.init() await mainGit.addConfig("user.name", "Roo Code") await mainGit.addConfig("user.email", "support@roocode.com") await fs.writeFile(path.join(workspaceDir, "main.txt"), "main content") await mainGit.add("main.txt") await mainGit.commit("Initial commit") vitest.spyOn(fileSearch, "executeRipgrep").mockImplementation(() => { return Promise.resolve([]) }) // First init to create the shadow repo const service1 = new RepoPerTaskCheckpointService("missing-test", shadowDir, workspaceDir, () => {}) await service1.initShadowGit() // Remove core.worktree from the shadow git config const shadowGit = simpleGit(shadowDir) await shadowGit.raw(["config", "--unset", "core.worktree"]) // Second init should throw because core.worktree is missing const service2 = new RepoPerTaskCheckpointService("missing-test-2", shadowDir, workspaceDir, () => {}) await expect(service2.initShadowGit()).rejects.toThrowError(/core\.worktree to be set/) } finally { vitest.restoreAllMocks() await fs.rm(shadowDir, { recursive: true, force: true }) await fs.rm(workspaceDir, { recursive: true, force: true }) } }) })