Skip to content

Commit 097bc69

Browse files
authored
fix(checkpoints): resolve incorrect commit location when GIT_DIR set in Dev Containers (#8811)
1 parent ea33973 commit 097bc69

File tree

2 files changed

+151
-3
lines changed

2 files changed

+151
-3
lines changed

src/services/checkpoints/ShadowCheckpointService.ts

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import * as path from "path"
44
import crypto from "crypto"
55
import EventEmitter from "events"
66

7-
import simpleGit, { SimpleGit } from "simple-git"
7+
import simpleGit, { SimpleGit, SimpleGitOptions } from "simple-git"
88
import pWaitFor from "p-wait-for"
99
import * as vscode from "vscode"
1010

@@ -15,6 +15,65 @@ import { t } from "../../i18n"
1515
import { CheckpointDiff, CheckpointResult, CheckpointEventMap } from "./types"
1616
import { getExcludePatterns } from "./excludes"
1717

18+
/**
19+
* Creates a SimpleGit instance with sanitized environment variables to prevent
20+
* interference from inherited git environment variables like GIT_DIR and GIT_WORK_TREE.
21+
* This ensures checkpoint operations always target the intended shadow repository.
22+
*
23+
* @param baseDir - The directory where git operations should be executed
24+
* @returns A SimpleGit instance with sanitized environment
25+
*/
26+
function createSanitizedGit(baseDir: string): SimpleGit {
27+
// Create a clean environment by explicitly unsetting git-related environment variables
28+
// that could interfere with checkpoint operations
29+
const sanitizedEnv: Record<string, string> = {}
30+
const removedVars: string[] = []
31+
32+
// Copy all environment variables except git-specific ones
33+
for (const [key, value] of Object.entries(process.env)) {
34+
// Skip git environment variables that would override repository location
35+
if (
36+
key === "GIT_DIR" ||
37+
key === "GIT_WORK_TREE" ||
38+
key === "GIT_INDEX_FILE" ||
39+
key === "GIT_OBJECT_DIRECTORY" ||
40+
key === "GIT_ALTERNATE_OBJECT_DIRECTORIES" ||
41+
key === "GIT_CEILING_DIRECTORIES"
42+
) {
43+
removedVars.push(`${key}=${value}`)
44+
continue
45+
}
46+
47+
// Only include defined values
48+
if (value !== undefined) {
49+
sanitizedEnv[key] = value
50+
}
51+
}
52+
53+
// Log which git env vars were removed (helps with debugging Dev Container issues)
54+
if (removedVars.length > 0) {
55+
console.log(
56+
`[createSanitizedGit] Removed git environment variables for checkpoint isolation: ${removedVars.join(", ")}`,
57+
)
58+
}
59+
60+
const options: Partial<SimpleGitOptions> = {
61+
baseDir,
62+
config: [],
63+
}
64+
65+
// Create git instance and set the sanitized environment
66+
const git = simpleGit(options)
67+
68+
// Use the .env() method to set the complete sanitized environment
69+
// This replaces the inherited environment with our sanitized version
70+
git.env(sanitizedEnv)
71+
72+
console.log(`[createSanitizedGit] Created git instance for baseDir: ${baseDir}`)
73+
74+
return git
75+
}
76+
1877
export abstract class ShadowCheckpointService extends EventEmitter {
1978
public readonly taskId: string
2079
public readonly checkpointsDir: string
@@ -85,7 +144,7 @@ export abstract class ShadowCheckpointService extends EventEmitter {
85144
}
86145

87146
await fs.mkdir(this.checkpointsDir, { recursive: true })
88-
const git = simpleGit(this.checkpointsDir)
147+
const git = createSanitizedGit(this.checkpointsDir)
89148
const gitVersion = await git.version()
90149
this.log(`[${this.constructor.name}#create] git = ${gitVersion}`)
91150

@@ -391,7 +450,7 @@ export abstract class ShadowCheckpointService extends EventEmitter {
391450
}) {
392451
const workspaceRepoDir = this.workspaceRepoDir({ globalStorageDir, workspaceDir })
393452
const branchName = `roo-${taskId}`
394-
const git = simpleGit(workspaceRepoDir)
453+
const git = createSanitizedGit(workspaceRepoDir)
395454
const success = await this.deleteBranch(git, branchName)
396455

397456
if (success) {

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

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -823,6 +823,95 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])(
823823
// File should be back to original state
824824
expect(await fs.readFile(testFile, "utf-8")).toBe("Hello, world!")
825825
})
826+
827+
it("isolates checkpoint operations from GIT_DIR environment variable", async () => {
828+
// This test verifies the fix for the issue where GIT_DIR environment variable
829+
// causes checkpoint commits to go to the wrong repository.
830+
// In the real-world Dev Container scenario, GIT_DIR is set BEFORE Roo starts,
831+
// so we need to set it BEFORE creating the checkpoint service.
832+
833+
// Create a separate git directory to simulate GIT_DIR pointing elsewhere
834+
const externalGitDir = path.join(tmpDir, `external-git-${Date.now()}`)
835+
await fs.mkdir(externalGitDir, { recursive: true })
836+
const externalGit = simpleGit(externalGitDir)
837+
await externalGit.init()
838+
await externalGit.addConfig("user.name", "External User")
839+
await externalGit.addConfig("user.email", "[email protected]")
840+
841+
// Create and commit a file in the external repo
842+
const externalFile = path.join(externalGitDir, "external.txt")
843+
await fs.writeFile(externalFile, "External content")
844+
await externalGit.add(".")
845+
await externalGit.commit("External commit")
846+
847+
// Store the original commit count in the external repo
848+
const externalLogBefore = await externalGit.log()
849+
const externalCommitCountBefore = externalLogBefore.total
850+
851+
// Initialize the workspace repo BEFORE setting GIT_DIR
852+
// (In Dev Containers, the workspace repo already exists before GIT_DIR is set)
853+
const testShadowDir = path.join(tmpDir, `shadow-git-dir-test-${Date.now()}`)
854+
const testWorkspaceDir = path.join(tmpDir, `workspace-git-dir-test-${Date.now()}`)
855+
const testRepo = await initWorkspaceRepo({ workspaceDir: testWorkspaceDir })
856+
857+
// Set GIT_DIR to point to the external repository BEFORE creating the service
858+
// This simulates the Dev Container environment where GIT_DIR is already set
859+
const originalGitDir = process.env.GIT_DIR
860+
const externalDotGit = path.join(externalGitDir, ".git")
861+
process.env.GIT_DIR = externalDotGit
862+
863+
try {
864+
// Create a new checkpoint service with GIT_DIR already set
865+
// This is the key difference - we're creating the service
866+
// while GIT_DIR is set, just like in a real Dev Container
867+
const testService = await klass.create({
868+
taskId: `test-git-dir-${Date.now()}`,
869+
shadowDir: testShadowDir,
870+
workspaceDir: testWorkspaceDir,
871+
log: () => {},
872+
})
873+
await testService.initShadowGit()
874+
875+
// Make a change in the workspace and save a checkpoint
876+
const testWorkspaceFile = path.join(testWorkspaceDir, "test.txt")
877+
await fs.writeFile(testWorkspaceFile, "Modified with GIT_DIR set")
878+
const commit = await testService.saveCheckpoint("Checkpoint with GIT_DIR set")
879+
expect(commit?.commit).toBeTruthy()
880+
881+
// Verify the checkpoint was saved in the shadow repo, not the external repo
882+
// Temporarily clear GIT_DIR to check the external repo
883+
delete process.env.GIT_DIR
884+
const externalGitCheck = simpleGit(externalGitDir)
885+
const externalLogAfter = await externalGitCheck.log()
886+
const externalCommitCountAfter = externalLogAfter.total
887+
// Restore GIT_DIR
888+
process.env.GIT_DIR = externalDotGit
889+
890+
// External repo should have the same number of commits (no new commits)
891+
expect(externalCommitCountAfter).toBe(externalCommitCountBefore)
892+
893+
// Verify the checkpoint is accessible in the shadow repo
894+
const diff = await testService.getDiff({ to: commit!.commit })
895+
expect(diff).toHaveLength(1)
896+
expect(diff[0].paths.relative).toBe("test.txt")
897+
expect(diff[0].content.after).toBe("Modified with GIT_DIR set")
898+
899+
// Verify we can restore the checkpoint
900+
await fs.writeFile(testWorkspaceFile, "Another modification")
901+
await testService.restoreCheckpoint(commit!.commit)
902+
expect(await fs.readFile(testWorkspaceFile, "utf-8")).toBe("Modified with GIT_DIR set")
903+
} finally {
904+
// Restore original GIT_DIR
905+
if (originalGitDir !== undefined) {
906+
process.env.GIT_DIR = originalGitDir
907+
} else {
908+
delete process.env.GIT_DIR
909+
}
910+
911+
// Clean up external git directory
912+
await fs.rm(externalGitDir, { recursive: true, force: true })
913+
}
914+
})
826915
})
827916
},
828917
)

0 commit comments

Comments
 (0)