Skip to content
Open
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
208 changes: 154 additions & 54 deletions src/services/checkpoints/ShadowCheckpointService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ 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"
import { getExcludePatterns } from "./excludes"
Expand Down Expand Up @@ -70,20 +69,6 @@ export abstract class ShadowCheckpointService extends EventEmitter {
throw new Error("Shadow git repo already initialized")
}

const nestedGitPath = await this.getNestedGitRepository()

if (nestedGitPath) {
// Show persistent error message with the offending path
const relativePath = path.relative(this.workspaceDir, nestedGitPath)
const message = t("common:errors.nested_git_repos_warning", { path: relativePath })
vscode.window.showErrorMessage(message)

throw new Error(
`Checkpoints are disabled because a nested git repository was detected at: ${relativePath}. ` +
"Please remove or relocate nested git repositories to use the checkpoints feature.",
)
}

await fs.mkdir(this.checkpointsDir, { recursive: true })
const git = simpleGit(this.checkpointsDir)
const gitVersion = await git.version()
Expand Down Expand Up @@ -152,63 +137,178 @@ export abstract class ShadowCheckpointService extends EventEmitter {

private async stageAll(git: SimpleGit) {
try {
await git.add([".", "--ignore-errors"])
// Find all nested repos to exclude
const nestedRepos = await this.findNestedRepos(git)

if (nestedRepos.length > 0) {
this.log(
`[${this.constructor.name}#stageAll] excluding ${nestedRepos.length} nested repos: ${nestedRepos.join(", ")}`,
)
}

// Remove any existing gitlinks from the index before staging
for (const repoPath of nestedRepos) {
try {
// Normalize to POSIX for git pathspec compatibility
const posixPath = repoPath.replace(/\\/g, "/")
await git.raw(["rm", "--cached", "--ignore-unmatch", "-r", posixPath])
} catch (error) {
this.log(
`[${this.constructor.name}#stageAll] failed to remove cached gitlink ${repoPath}: ${error instanceof Error ? error.message : String(error)}`,
)
}
}

// Build add command with pathspec excludes
const addArgs: string[] = ["-A", ":/"]
for (const repoPath of nestedRepos) {
// Normalize to POSIX for git pathspec compatibility
const posixPath = repoPath.replace(/\\/g, "/")
addArgs.push(`:(exclude)${posixPath}/`)
}

// Stage files
await git.add(addArgs)

// Enhanced safety check: verify no mode 160000 entries in staging area
const stagedFiles = await git.raw(["ls-files", "-s", "--cached"])
const gitlinkEntries = stagedFiles
.split("\n")
.filter((line) => line.startsWith("160000"))
.map((line) => {
// Parse git ls-files output: <mode> <object> <stage> <filename>
// Handle filenames with spaces by splitting only on first 3 whitespace groups
const match = line.match(/^(\S+)\s+(\S+)\s+(\S+)\s+(.+)$/)
return match ? match[4] : null
})
.filter(Boolean)

if (gitlinkEntries.length > 0) {
throw new Error(
`Gitlink entries detected in staging area: ${gitlinkEntries.join(", ")} - this should not happen`,
)
}

// Additional check for .gitmodules changes
const diffSummary = await git.raw(["diff", "--cached", "--name-only"])
if (diffSummary.includes(".gitmodules")) {
this.log(`[${this.constructor.name}#stageAll] warning: .gitmodules changes detected in staging area`)
}
} catch (error) {
this.log(
`[${this.constructor.name}#stageAll] failed to add files to git: ${error instanceof Error ? error.message : String(error)}`,
)
throw error
}
}

private async getNestedGitRepository(): Promise<string | null> {
private async findNestedRepos(git: SimpleGit): Promise<string[]> {
const nestedRepos = new Set<string>()

// 1. From .gitmodules (declared submodules)
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"
const modulesPath = path.join(this.workspaceDir, ".gitmodules")
const config = await git.raw(["config", "-f", modulesPath, "--get-regexp", "^submodule\\..*\\.path$"])
for (const line of config.split("\n")) {
const match = line.match(/submodule\..*\.path\s+(.+)/)
if (match) {
// Normalize paths to POSIX format for git pathspec compatibility
const normalizedPath = match[1].replace(/\\/g, "/")
nestedRepos.add(normalizedPath)
}
}
} catch (error) {
// No .gitmodules file is expected in most cases, only log if it's a real error
if (error instanceof Error && !error.message.includes("exit code 1")) {
this.log(
`[${this.constructor.name}#findNestedRepos] warning: failed to read .gitmodules: ${error.message}`,
)
})

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
// 2. From index (gitlinks with mode 160000)
try {
const lsFiles = await git.raw(["ls-files", "-s"])
for (const line of lsFiles.split("\n")) {
if (line.startsWith("160000")) {
// Parse git ls-files output: <mode> <object> <stage> <filename>
// Handle filenames with spaces by splitting only on first 3 whitespace groups
const match = line.match(/^(\S+)\s+(\S+)\s+(\S+)\s+(.+)$/)
if (match && match[4]) {
nestedRepos.add(match[4])
}
}
}
} catch (error) {
this.log(
`[${this.constructor.name}#findNestedRepos] warning: failed to list files from index: ${error instanceof Error ? error.message : String(error)}`,
)
}

const absolutePath = path.join(this.workspaceDir, repoDir)
// 3. From filesystem (any nested .git directory or worktree)
try {
const gitDirs = await executeRipgrep({
args: ["--files", "--hidden", "--follow", "-g", "**/.git/HEAD", this.workspaceDir],
workspacePath: this.workspaceDir,
})

this.log(
`[${this.constructor.name}#getNestedGitRepository] found ${nestedGitPaths.length} nested git repositories, first at: ${repoDir}`,
)
return absolutePath
for (const result of gitDirs) {
if (result.type === "file") {
const normalizedPath = result.path.replace(/\\/g, "/")
if (
normalizedPath.includes(".git/HEAD") &&
!normalizedPath.startsWith(".git/") &&
normalizedPath !== ".git/HEAD"
) {
// Extract repo directory (remove .git/HEAD)
const gitDir = path.dirname(normalizedPath)
const repoDir = path.dirname(gitDir)
nestedRepos.add(repoDir)
}
}
}

return null
} catch (error) {
this.log(
`[${this.constructor.name}#getNestedGitRepository] failed to check for nested git repos: ${error instanceof Error ? error.message : String(error)}`,
`[${this.constructor.name}#findNestedRepos] failed to search filesystem: ${error instanceof Error ? error.message : String(error)}`,
)
}

// 4. From filesystem (git worktrees - .git files pointing to worktree)
try {
const gitFiles = await executeRipgrep({
args: ["--files", "--hidden", "--follow", "-g", "**/.git", this.workspaceDir],
workspacePath: this.workspaceDir,
})

// If we can't check, assume there are no nested repos to avoid blocking the feature.
return null
for (const result of gitFiles) {
if (result.type === "file") {
const normalizedPath = result.path.replace(/\\/g, "/")
// Check if this is a .git file (not directory) and not the root
if (normalizedPath.endsWith("/.git") && normalizedPath !== ".git") {
try {
// Read the .git file to check if it's a worktree
const gitFilePath = path.join(this.workspaceDir, result.path)
const content = await fs.readFile(gitFilePath, "utf8")
if (content.trim().startsWith("gitdir:")) {
// This is a worktree - exclude its directory
const repoDir = path.dirname(normalizedPath)
nestedRepos.add(repoDir)
}
} catch (error) {
this.log(
`[${this.constructor.name}#findNestedRepos] warning: failed to read .git file at ${result.path}: ${error instanceof Error ? error.message : String(error)}`,
)
}
}
}
}
} catch (error) {
this.log(
`[${this.constructor.name}#findNestedRepos] failed to search for worktrees: ${error instanceof Error ? error.message : String(error)}`,
)
}

return Array.from(nestedRepos).filter((p) => p && p !== ".")
}

private async getShadowGitConfigWorktree(git: SimpleGit) {
Expand Down
Loading