Skip to content

Commit 2325d8b

Browse files
committed
Improve file excludes for checkpoints service
1 parent eea09c5 commit 2325d8b

File tree

5 files changed

+537
-125
lines changed

5 files changed

+537
-125
lines changed

src/services/checkpoints/ShadowCheckpointService.ts

Lines changed: 19 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@ import EventEmitter from "events"
66
import simpleGit, { SimpleGit } from "simple-git"
77
import { globby } from "globby"
88

9-
import { GIT_DISABLED_SUFFIX, GIT_EXCLUDES } from "./constants"
9+
import { fileExistsAtPath } from "../../utils/fs"
10+
11+
import { GIT_DISABLED_SUFFIX } from "./constants"
1012
import { CheckpointDiff, CheckpointResult, CheckpointEventMap } from "./types"
13+
import { getExcludePatterns } from "./excludes"
1114

1215
export abstract class ShadowCheckpointService extends EventEmitter {
1316
public readonly taskId: string
@@ -65,12 +68,6 @@ export abstract class ShadowCheckpointService extends EventEmitter {
6568
const gitVersion = await git.version()
6669
this.log(`[${this.constructor.name}#create] git = ${gitVersion}`)
6770

68-
const fileExistsAtPath = (path: string) =>
69-
fs
70-
.access(path)
71-
.then(() => true)
72-
.catch(() => false)
73-
7471
let created = false
7572
const startTime = Date.now()
7673

@@ -84,48 +81,24 @@ export abstract class ShadowCheckpointService extends EventEmitter {
8481
)
8582
}
8683

84+
await this.writeExcludeFile()
8785
this.baseHash = await git.revparse(["HEAD"])
8886
} else {
8987
this.log(`[${this.constructor.name}#initShadowGit] creating shadow git repo at ${this.checkpointsDir}`)
90-
9188
await git.init()
9289
await git.addConfig("core.worktree", this.workspaceDir) // Sets the working tree to the current workspace.
9390
await git.addConfig("commit.gpgSign", "false") // Disable commit signing for shadow repo.
9491
await git.addConfig("user.name", "Roo Code")
9592
await git.addConfig("user.email", "[email protected]")
96-
97-
let lfsPatterns: string[] = [] // Get LFS patterns from workspace if they exist.
98-
99-
try {
100-
const attributesPath = path.join(this.workspaceDir, ".gitattributes")
101-
102-
if (await fileExistsAtPath(attributesPath)) {
103-
lfsPatterns = (await fs.readFile(attributesPath, "utf8"))
104-
.split("\n")
105-
.filter((line) => line.includes("filter=lfs"))
106-
.map((line) => line.split(" ")[0].trim())
107-
}
108-
} catch (error) {
109-
this.log(
110-
`[${this.constructor.name}#initShadowGit] failed to read .gitattributes: ${error instanceof Error ? error.message : String(error)}`,
111-
)
112-
}
113-
114-
// Add basic excludes directly in git config, while respecting any
115-
// .gitignore in the workspace.
116-
// .git/info/exclude is local to the shadow git repo, so it's not
117-
// shared with the main repo - and won't conflict with user's
118-
// .gitignore.
119-
await fs.mkdir(path.join(this.dotGitDir, "info"), { recursive: true })
120-
const excludesPath = path.join(this.dotGitDir, "info", "exclude")
121-
await fs.writeFile(excludesPath, [...GIT_EXCLUDES, ...lfsPatterns].join("\n"))
93+
await this.writeExcludeFile()
12294
await this.stageAll(git)
12395
const { commit } = await git.commit("initial commit", { "--allow-empty": null })
12496
this.baseHash = commit
12597
created = true
12698
}
12799

128100
const duration = Date.now() - startTime
101+
129102
this.log(
130103
`[${this.constructor.name}#initShadowGit] initialized shadow repo with base commit ${this.baseHash} in ${duration}ms`,
131104
)
@@ -145,8 +118,18 @@ export abstract class ShadowCheckpointService extends EventEmitter {
145118
return { created, duration }
146119
}
147120

121+
// Add basic excludes directly in git config, while respecting any
122+
// .gitignore in the workspace.
123+
// .git/info/exclude is local to the shadow git repo, so it's not
124+
// shared with the main repo - and won't conflict with user's
125+
// .gitignore.
126+
protected async writeExcludeFile() {
127+
await fs.mkdir(path.join(this.dotGitDir, "info"), { recursive: true })
128+
const patterns = await getExcludePatterns(this.workspaceDir)
129+
await fs.writeFile(path.join(this.dotGitDir, "info", "exclude"), patterns.join("\n"))
130+
}
131+
148132
private async stageAll(git: SimpleGit) {
149-
// await writeExcludesFile(gitPath, await getLfsPatterns(this.cwd)).
150133
await this.renameNestedGitRepos(true)
151134

152135
try {
@@ -188,6 +171,7 @@ export abstract class ShadowCheckpointService extends EventEmitter {
188171

189172
try {
190173
await fs.rename(fullPath, newPath)
174+
191175
this.log(
192176
`[${this.constructor.name}#renameNestedGitRepos] ${disable ? "disabled" : "enabled"} nested git repo ${gitPath}`,
193177
)

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

Lines changed: 149 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { EventEmitter } from "events"
77

88
import { simpleGit, SimpleGit } from "simple-git"
99

10+
import { fileExistsAtPath } from "../../../utils/fs"
1011
import { RepoPerTaskCheckpointService } from "../RepoPerTaskCheckpointService"
1112
import { RepoPerWorkspaceCheckpointService } from "../RepoPerWorkspaceCheckpointService"
1213

@@ -16,7 +17,7 @@ jest.mock("globby", () => ({
1617

1718
const tmpDir = path.join(os.tmpdir(), "CheckpointService")
1819

19-
const initRepo = async ({
20+
const initWorkspaceRepo = async ({
2021
workspaceDir,
2122
userName = "Roo Code",
2223
userEmail = "[email protected]",
@@ -64,7 +65,7 @@ describe.each([
6465

6566
const shadowDir = path.join(tmpDir, `${prefix}-${Date.now()}`)
6667
const workspaceDir = path.join(tmpDir, `workspace-${Date.now()}`)
67-
const repo = await initRepo({ workspaceDir })
68+
const repo = await initWorkspaceRepo({ workspaceDir })
6869

6970
workspaceGit = repo.git
7071
testFile = repo.testFile
@@ -298,6 +299,52 @@ describe.each([
298299
await expect(fs.readFile(testFile, "utf-8")).rejects.toThrow()
299300
await expect(fs.readFile(untrackedFile, "utf-8")).rejects.toThrow()
300301
})
302+
303+
it("does not create a checkpoint for ignored files", async () => {
304+
// Create a file that matches an ignored pattern (e.g., .log file).
305+
const ignoredFile = path.join(service.workspaceDir, "ignored.log")
306+
await fs.writeFile(ignoredFile, "Initial ignored content")
307+
308+
const commit = await service.saveCheckpoint("Ignored file checkpoint")
309+
expect(commit?.commit).toBeFalsy()
310+
311+
await fs.writeFile(ignoredFile, "Modified ignored content")
312+
313+
const commit2 = await service.saveCheckpoint("Ignored file modified checkpoint")
314+
expect(commit2?.commit).toBeFalsy()
315+
316+
expect(await fs.readFile(ignoredFile, "utf-8")).toBe("Modified ignored content")
317+
})
318+
319+
it("does not create a checkpoint for LFS files", async () => {
320+
// Create a .gitattributes file with LFS patterns.
321+
const gitattributesPath = path.join(service.workspaceDir, ".gitattributes")
322+
await fs.writeFile(gitattributesPath, "*.lfs filter=lfs diff=lfs merge=lfs -text")
323+
324+
// Re-initialize the service to trigger a write to .git/info/exclude.
325+
service = new klass(service.taskId, service.checkpointsDir, service.workspaceDir, () => {})
326+
const excludesPath = path.join(service.checkpointsDir, ".git", "info", "exclude")
327+
expect((await fs.readFile(excludesPath, "utf-8")).split("\n")).not.toContain("*.lfs")
328+
await service.initShadowGit()
329+
expect((await fs.readFile(excludesPath, "utf-8")).split("\n")).toContain("*.lfs")
330+
331+
const commit0 = await service.saveCheckpoint("Add gitattributes")
332+
expect(commit0?.commit).toBeTruthy()
333+
334+
// Create a file that matches an LFS pattern.
335+
const lfsFile = path.join(service.workspaceDir, "foo.lfs")
336+
await fs.writeFile(lfsFile, "Binary file content simulation")
337+
338+
const commit = await service.saveCheckpoint("LFS file checkpoint")
339+
expect(commit?.commit).toBeFalsy()
340+
341+
await fs.writeFile(lfsFile, "Modified binary content")
342+
343+
const commit2 = await service.saveCheckpoint("LFS file modified checkpoint")
344+
expect(commit2?.commit).toBeFalsy()
345+
346+
expect(await fs.readFile(lfsFile, "utf-8")).toBe("Modified binary content")
347+
})
301348
})
302349

303350
describe(`${klass.name}#create`, () => {
@@ -337,6 +384,106 @@ describe.each([
337384
})
338385
})
339386

387+
describe(`${klass.name}#renameNestedGitRepos`, () => {
388+
it("handles nested git repositories during initialization", async () => {
389+
// Create a new temporary workspace and service for this test.
390+
const shadowDir = path.join(tmpDir, `${prefix}-nested-git-${Date.now()}`)
391+
const workspaceDir = path.join(tmpDir, `workspace-nested-git-${Date.now()}`)
392+
393+
// Create a primary workspace repo.
394+
await fs.mkdir(workspaceDir, { recursive: true })
395+
const mainGit = simpleGit(workspaceDir)
396+
await mainGit.init()
397+
await mainGit.addConfig("user.name", "Roo Code")
398+
await mainGit.addConfig("user.email", "[email protected]")
399+
400+
// Create a nested repo inside the workspace.
401+
const nestedRepoPath = path.join(workspaceDir, "nested-project")
402+
await fs.mkdir(nestedRepoPath, { recursive: true })
403+
const nestedGit = simpleGit(nestedRepoPath)
404+
await nestedGit.init()
405+
await nestedGit.addConfig("user.name", "Roo Code")
406+
await nestedGit.addConfig("user.email", "[email protected]")
407+
408+
// Add a file to the nested repo.
409+
const nestedFile = path.join(nestedRepoPath, "nested-file.txt")
410+
await fs.writeFile(nestedFile, "Content in nested repo")
411+
await nestedGit.add(".")
412+
await nestedGit.commit("Initial commit in nested repo")
413+
414+
// Create a test file in the main workspace.
415+
const mainFile = path.join(workspaceDir, "main-file.txt")
416+
await fs.writeFile(mainFile, "Content in main repo")
417+
await mainGit.add(".")
418+
await mainGit.commit("Initial commit in main repo")
419+
420+
// Confirm nested git directory exists before initialization.
421+
const nestedGitDir = path.join(nestedRepoPath, ".git")
422+
const nestedGitDisabledDir = `${nestedGitDir}_disabled`
423+
expect(await fileExistsAtPath(nestedGitDir)).toBe(true)
424+
expect(await fileExistsAtPath(nestedGitDisabledDir)).toBe(false)
425+
426+
// Configure globby mock to return our nested git repository.
427+
const relativeGitPath = path.relative(workspaceDir, nestedGitDir)
428+
429+
jest.mocked(require("globby").globby).mockImplementation((pattern: string | string[]) => {
430+
if (pattern === "**/.git") {
431+
return Promise.resolve([relativeGitPath])
432+
} else if (pattern === "**/.git_disabled") {
433+
return Promise.resolve([`${relativeGitPath}_disabled`])
434+
}
435+
436+
return Promise.resolve([])
437+
})
438+
439+
// Create a spy on fs.rename to track when it's called.
440+
const renameSpy = jest.spyOn(fs, "rename")
441+
442+
// Initialize the shadow git service.
443+
const service = new klass(taskId, shadowDir, workspaceDir, () => {})
444+
445+
// Override renameNestedGitRepos to track calls.
446+
const originalRenameMethod = service["renameNestedGitRepos"].bind(service)
447+
let disableCall = false
448+
let enableCall = false
449+
450+
service["renameNestedGitRepos"] = async (disable: boolean) => {
451+
if (disable) {
452+
disableCall = true
453+
} else {
454+
enableCall = true
455+
}
456+
457+
return originalRenameMethod(disable)
458+
}
459+
460+
// Initialize the shadow git repo.
461+
await service.initShadowGit()
462+
463+
// Verify both disable and enable were called.
464+
expect(disableCall).toBe(true)
465+
expect(enableCall).toBe(true)
466+
467+
// Verify rename was called with correct paths.
468+
const renameCallsArgs = renameSpy.mock.calls.map((call) => call[0] + " -> " + call[1])
469+
expect(
470+
renameCallsArgs.some((args) => args.includes(nestedGitDir) && args.includes(nestedGitDisabledDir)),
471+
).toBe(true)
472+
expect(
473+
renameCallsArgs.some((args) => args.includes(nestedGitDisabledDir) && args.includes(nestedGitDir)),
474+
).toBe(true)
475+
476+
// Verify the nested git directory is back to normal after initialization.
477+
expect(await fileExistsAtPath(nestedGitDir)).toBe(true)
478+
expect(await fileExistsAtPath(nestedGitDisabledDir)).toBe(false)
479+
480+
// Clean up.
481+
renameSpy.mockRestore()
482+
await fs.rm(shadowDir, { recursive: true, force: true })
483+
await fs.rm(workspaceDir, { recursive: true, force: true })
484+
})
485+
})
486+
340487
describe(`${klass.name}#events`, () => {
341488
it("emits initialize event when service is created", async () => {
342489
const shadowDir = path.join(tmpDir, `${prefix}3-${Date.now()}`)

0 commit comments

Comments
 (0)