mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-21 00:21:25 +00:00
1102 lines
44 KiB
TypeScript
1102 lines
44 KiB
TypeScript
// 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 })
|
|
}
|
|
})
|
|
})
|