Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 82 additions & 40 deletions src/services/checkpoints/ShadowCheckpointService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import pWaitFor from "p-wait-for"
import * as vscode from "vscode"

import { fileExistsAtPath } from "../../utils/fs"
import { executeRipgrep } from "../../services/search/file-search"
import { t } from "../../i18n"

import { CheckpointDiff, CheckpointResult, CheckpointEventMap } from "./types"
Expand Down Expand Up @@ -221,45 +220,7 @@ export abstract class ShadowCheckpointService extends EventEmitter {

private async getNestedGitRepository(): Promise<string | null> {
try {
// Find all .git/HEAD files that are not at the root level.
const args = ["--files", "--hidden", "--follow", "-g", "**/.git/HEAD", this.workspaceDir]

const gitPaths = await executeRipgrep({ args, workspacePath: this.workspaceDir })

// Filter to only include nested git directories (not the root .git).
// Since we're searching for HEAD files, we expect type to be "file"
const nestedGitPaths = gitPaths.filter(({ type, path: filePath }) => {
// Check if it's a file and is a nested .git/HEAD (not at root)
if (type !== "file") return false

// Ensure it's a .git/HEAD file and not the root one
const normalizedPath = filePath.replace(/\\/g, "/")
return (
normalizedPath.includes(".git/HEAD") &&
!normalizedPath.startsWith(".git/") &&
normalizedPath !== ".git/HEAD"
)
})

if (nestedGitPaths.length > 0) {
// Get the first nested git repository path
// Remove .git/HEAD from the path to get the repository directory
const headPath = nestedGitPaths[0].path

// Use path module to properly extract the repository directory
// The HEAD file is at .git/HEAD, so we need to go up two directories
const gitDir = path.dirname(headPath) // removes HEAD, gives us .git
const repoDir = path.dirname(gitDir) // removes .git, gives us the repo directory

const absolutePath = path.join(this.workspaceDir, repoDir)

this.log(
`[${this.constructor.name}#getNestedGitRepository] found ${nestedGitPaths.length} nested git repositories, first at: ${repoDir}`,
)
return absolutePath
}

return null
return await this.findNestedGitEntry(this.workspaceDir, /* isRoot */ true)
} catch (error) {
this.log(
`[${this.constructor.name}#getNestedGitRepository] failed to check for nested git repos: ${error instanceof Error ? error.message : String(error)}`,
Expand All @@ -270,6 +231,87 @@ export abstract class ShadowCheckpointService extends EventEmitter {
}
}

/**
* Recursively walks the workspace directory to find nested .git entries.
*
* This detects both:
* - `.git` directories (standard nested repos)
* - `.git` files containing a `gitdir:` pointer (submodules/worktrees)
*
* A `.git` file that does NOT begin with `gitdir:` is ignored to avoid
* false positives from stray files.
*
* It uses `lstat` semantics via `withFileTypes` so symbolic links are never
* followed, preventing false positives from symlinks pointing outside the
* workspace.
*
* The root-level `.git` entry is always skipped so the workspace's own
* repository is not treated as nested.
*/
private async findNestedGitEntry(dir: string, isRoot: boolean): Promise<string | null> {
let entries: import("fs").Dirent[]

try {
entries = await fs.readdir(dir, { withFileTypes: true })
} catch {
// Directory unreadable (permissions, etc.) -- skip silently.
return null
}

// Look for a .git entry in this directory (skip root level).
if (!isRoot) {
const gitEntry = entries.find((e) => e.name === ".git")

if (gitEntry) {
// .git directories are always valid nested repos.
// .git files are only valid if they contain a "gitdir:" pointer
// (used by submodules and worktrees). Stray .git files without
// "gitdir:" are not treated as nested repos to avoid false positives.
let isNestedRepo = gitEntry.isDirectory()

if (!isNestedRepo && !gitEntry.isSymbolicLink()) {
try {
const content = await fs.readFile(path.join(dir, ".git"), "utf-8")
isNestedRepo = content.trimStart().toLowerCase().startsWith("gitdir:")
} catch {
// Unreadable .git file -- skip gracefully.
}
}

if (isNestedRepo) {
this.log(
`[${this.constructor.name}#getNestedGitRepository] found nested git repository at: ${path.relative(this.workspaceDir, dir)}`,
)
return dir
}
}
}

// Recurse into real subdirectories only (skip symlinks, .git dirs,
// and node_modules for performance).
for (const entry of entries) {
if (entry.isSymbolicLink()) {
continue
}

if (!entry.isDirectory()) {
continue
}

if (entry.name === ".git" || entry.name === "node_modules") {
continue
}

const result = await this.findNestedGitEntry(path.join(dir, entry.name), false)

if (result) {
return result
}
}

return null
}

private async getShadowGitConfigWorktree(git: SimpleGit) {
if (!this.shadowGitConfigWorktree) {
try {
Expand Down
129 changes: 97 additions & 32 deletions src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ 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"

Expand Down Expand Up @@ -379,7 +378,7 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])(
})

describe(`${klass.name}#hasNestedGitRepositories`, () => {
it("throws error when nested git repositories are detected during initialization", async () => {
it("throws error when nested git directories 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()}`)
Expand All @@ -391,7 +390,7 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])(
await mainGit.addConfig("user.name", "Roo Code")
await mainGit.addConfig("user.email", "support@roocode.com")

// Create a nested repo inside the workspace.
// Create a nested repo inside the workspace (standard .git directory).
const nestedRepoPath = path.join(workspaceDir, "nested-project")
await fs.mkdir(nestedRepoPath, { recursive: true })
const nestedGit = simpleGit(nestedRepoPath)
Expand All @@ -413,42 +412,114 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])(

// 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.
await expect(service.initShadowGit()).rejects.toThrowError(
/Checkpoints are disabled because a nested git repository was detected at:/,
)

// Clean up.
await fs.rm(shadowDir, { recursive: true, force: true })
await fs.rm(workspaceDir, { recursive: true, force: true })
})

it("throws error when nested .git pointer file is detected (submodule/worktree)", async () => {
// Create a new temporary workspace and service for this test.
const shadowDir = path.join(tmpDir, `${prefix}-nested-gitfile-${Date.now()}`)
const workspaceDir = path.join(tmpDir, `workspace-nested-gitfile-${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")

// Simulate a submodule: create a directory with a .git *file*
// (not a directory) containing a gitdir pointer.
const submodulePath = path.join(workspaceDir, "my-submodule")
await fs.mkdir(submodulePath, { recursive: true })
await fs.writeFile(path.join(submodulePath, ".git"), "gitdir: ../.git/modules/my-submodule\n")

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
// Verify that initialization throws when a .git file (submodule pointer) is detected.
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("ignores stray .git file without gitdir: content (no false positive)", async () => {
// Create a new temporary workspace and service for this test.
const shadowDir = path.join(tmpDir, `${prefix}-stray-gitfile-${Date.now()}`)
const workspaceDir = path.join(tmpDir, `workspace-stray-gitfile-${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 stray .git file that does NOT contain "gitdir:" --
// this should NOT be treated as a nested repo.
const strayDir = path.join(workspaceDir, "some-tool-output")
await fs.mkdir(strayDir, { recursive: true })
await fs.writeFile(path.join(strayDir, ".git"), "not a gitdir pointer\n")

const service = new klass(taskId, shadowDir, workspaceDir, () => {})

// Initialization should succeed because the .git file is not a valid pointer.
await expect(service.initShadowGit()).resolves.not.toThrow()
expect(service.isInitialized).toBe(true)

// Clean up.
await fs.rm(shadowDir, { recursive: true, force: true })
await fs.rm(workspaceDir, { recursive: true, force: true })
})

it("does not follow symlinks outside the workspace", async () => {
// Create a new temporary workspace and service for this test.
const shadowDir = path.join(tmpDir, `${prefix}-symlink-${Date.now()}`)
const workspaceDir = path.join(tmpDir, `workspace-symlink-${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 an external directory with a nested git repo (outside the workspace).
const externalDir = path.join(tmpDir, `external-repo-${Date.now()}`)
const externalRepoDir = path.join(externalDir, "repo-with-git")
await fs.mkdir(externalRepoDir, { recursive: true })
const externalGit = simpleGit(externalRepoDir)
await externalGit.init()

// Create a symlink inside the workspace pointing to the external directory.
const symlinkPath = path.join(workspaceDir, "external-link")
await fs.symlink(externalDir, symlinkPath, "dir")

const service = new klass(taskId, shadowDir, workspaceDir, () => {})

// The symlink should NOT be followed, so no nested repo should be detected.
await expect(service.initShadowGit()).resolves.not.toThrow()
expect(service.isInitialized).toBe(true)

// Clean up.
await fs.rm(shadowDir, { recursive: true, force: true })
await fs.rm(workspaceDir, { recursive: true, force: true })
await fs.rm(externalDir, { 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()}`)
Expand All @@ -467,19 +538,13 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])(
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
// 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 })
})
Expand Down
Loading