From 3aff25717414ffbfbe25476118682b50b55af59d Mon Sep 17 00:00:00 2001 From: 0xMink Date: Mon, 9 Feb 2026 18:56:04 -0500 Subject: [PATCH 1/2] fix(checkpoints): exclude nested git repos instead of disabling checkpoints Replace the kill switch that disabled checkpoints entirely when nested git repositories were detected. Instead, detect nested repos at init time (via ripgrep dual scan for .git/HEAD and .git pointer files), exclude them from shadow git tracking, and warn the user. Safety enforcement is defense-in-depth: - Anchored exclude patterns in .git/info/exclude - Pathspec excludes in git add - Post-staging gitlink purge with triggered re-detection - Conservative filesystem-based fallback when detection fails - Filtered git add error handling (only suppresses "ignored paths") --- src/i18n/locales/en/common.json | 1 + .../checkpoints/ShadowCheckpointService.ts | 379 +++++++++++++++--- .../__tests__/ShadowCheckpointService.spec.ts | 369 +++++++++++++++-- 3 files changed, 674 insertions(+), 75 deletions(-) diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index d65fe183679..9c5eaf44773 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -35,6 +35,7 @@ "checkpoint_diff_with_next": "Changes compared with next checkpoint", "checkpoint_diff_since_first": "Changes since first checkpoint", "checkpoint_diff_to_current": "Changes to current workspace", + "nested_git_repos_excluded": "Checkpoints enabled. Excluded {{count}} nested git repositories: {{paths}}.", "nested_git_repos_warning": "Checkpoints are disabled because a nested git repository was detected at: {{path}}. To use checkpoints, please remove or relocate this nested git repository.", "no_workspace": "Please open a project folder first", "update_support_prompt": "Failed to update support prompt", diff --git a/src/services/checkpoints/ShadowCheckpointService.ts b/src/services/checkpoints/ShadowCheckpointService.ts index fee08b2fa4b..8b4aa6f7b46 100644 --- a/src/services/checkpoints/ShadowCheckpointService.ts +++ b/src/services/checkpoints/ShadowCheckpointService.ts @@ -81,6 +81,8 @@ export abstract class ShadowCheckpointService extends EventEmitter { protected _checkpoints: string[] = [] protected _baseHash?: string + protected nestedRepoPaths: string[] = [] + protected nestedRepoDetectionFailed = false protected readonly dotGitDir: string protected git?: SimpleGit @@ -129,18 +131,30 @@ export abstract class ShadowCheckpointService extends EventEmitter { throw new Error("Shadow git repo already initialized") } - const nestedGitPath = await this.getNestedGitRepository() + // Detect nested git repos once at init. Stored as workspace-relative POSIX + // paths (e.g. "frontend", "packages/foo") for use in exclude patterns and + // pathspec excludes. Detection is intentionally not repeated on every save + // because ripgrep scans are expensive in large workspaces; instead, stageAll() + // enforces safety post-staging by scanning for gitlink entries. + this.nestedRepoPaths = await this.findNestedGitRepositories() - 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.", + if (this.nestedRepoPaths.length > 0) { + const sortedPaths = [...this.nestedRepoPaths].sort() + this.log( + `[${this.constructor.name}#initShadowGit] found ${sortedPaths.length} nested git repositories, excluding from checkpoints: ${sortedPaths.join(", ")}`, ) + const maxDisplayPaths = 5 + const shown = sortedPaths.slice(0, maxDisplayPaths) + const remaining = sortedPaths.length - shown.length + const displayPaths = + remaining > 0 + ? `${shown.join(", ")}, \u2026 (${remaining} more)` + : shown.join(", ") + const message = t("common:errors.nested_git_repos_excluded", { + count: String(sortedPaths.length), + paths: displayPaths, + }) + vscode.window.showWarningMessage(message) } await fs.mkdir(this.checkpointsDir, { recursive: true }) @@ -206,67 +220,334 @@ export abstract class ShadowCheckpointService extends EventEmitter { protected async writeExcludeFile() { await fs.mkdir(path.join(this.dotGitDir, "info"), { recursive: true }) const patterns = await getExcludePatterns(this.workspaceDir) + + // Add anchored exclude patterns for nested git repos so the shadow git + // ignores them entirely. Leading "/" anchors the pattern to the worktree + // root, preventing accidental matches at other directory depths. + for (const repoRel of this.nestedRepoPaths) { + patterns.push(`/${repoRel}/`) + } + await fs.writeFile(path.join(this.dotGitDir, "info", "exclude"), patterns.join("\n")) } + private isIgnoredPathsAddError(error: unknown): boolean { + const msg = (error instanceof Error ? error.message : String(error)).toLowerCase() + return msg.includes("paths are ignored by one of your .gitignore files") + } + private async stageAll(git: SimpleGit) { + const addArgs: string[] = ["--ignore-errors", "--", "."] + + // Add top-anchored pathspec excludes for each known nested repo. + for (const repoRel of this.nestedRepoPaths) { + addArgs.push(`:(exclude,top)${repoRel}/`) + } + try { - await git.add([".", "--ignore-errors"]) + await git.add(addArgs) } catch (error) { + if (!this.isIgnoredPathsAddError(error)) { + // Real failure (index corruption, disk full, lock file, etc.) + // must abort — proceeding would produce a bad checkpoint. + throw error + } + this.log( - `[${this.constructor.name}#stageAll] failed to add files to git: ${error instanceof Error ? error.message : String(error)}`, + `[${this.constructor.name}#stageAll] git add ignored-paths warning (expected): ${error instanceof Error ? error.message : String(error)}`, + ) + } + + // Safety enforcement runs outside the git-add catch so their + // throws propagate to saveCheckpoint() and abort the save. + + // Post-staging safety layer 1: scan for gitlink entries (mode 160000) + // that indicate a nested repo slipped through despite the exclude file + // and pathspec. This catches repos added after init without requiring + // expensive re-detection on every save. + await this.purgeGitlinkEntries(git) + + // Post-staging safety layer 2: when detection failed, walk parent + // directories of staged paths on the filesystem to find .git + // markers that reveal nested repos. This catches the case where + // nested repo source files were staged as regular files (the + // shadow repo's .git/ exclude hides the marker from git, so no + // gitlink is created and purgeGitlinkEntries cannot help). Gated + // on nestedRepoDetectionFailed to avoid filesystem walks on the + // normal path where the exclude file and pathspec are trustworthy. + if (this.nestedRepoDetectionFailed) { + await this.rejectStagedNestedRepoContent(git) + } + } + + private async purgeGitlinkEntries(git: SimpleGit) { + const lsOutput = await git.raw(["ls-files", "--stage"]) + const gitlinkPaths: string[] = [] + + for (const line of lsOutput.split("\n")) { + if (line.startsWith("160000 ")) { + // Format: "160000 \t" + const tabIndex = line.indexOf("\t") + if (tabIndex !== -1) { + gitlinkPaths.push(line.substring(tabIndex + 1)) + } + } + } + + if (gitlinkPaths.length === 0) { + return + } + + this.log( + `[${this.constructor.name}#purgeGitlinkEntries] removing ${gitlinkPaths.length} gitlink entries: ${gitlinkPaths.join(", ")}`, + ) + + for (const gitlinkPath of gitlinkPaths) { + await git.raw(["rm", "--cached", "--ignore-unmatch", "-r", "--", gitlinkPath]) + } + + // Triggered re-detection: a gitlink that was not in our known list + // means a nested repo appeared after init. Update the exclude set so + // subsequent saves are protected by the exclude file too. + const newPaths = gitlinkPaths.filter( + (p) => !this.nestedRepoPaths.includes(p), + ) + + if (newPaths.length > 0) { + this.log( + `[${this.constructor.name}#purgeGitlinkEntries] discovered ${newPaths.length} new nested repos, updating excludes: ${newPaths.join(", ")}`, + ) + this.nestedRepoPaths = Array.from(new Set([...this.nestedRepoPaths, ...newPaths])) + await this.writeExcludeFile() + } + + // Verify all gitlinks are gone. If any persist, staging is unsafe and + // the checkpoint save must be aborted. + const verifyOutput = await git.raw(["ls-files", "--stage"]) + const remaining = verifyOutput.split("\n").filter((l) => l.startsWith("160000 ")) + if (remaining.length > 0) { + throw new Error( + `Staging is unsafe: ${remaining.length} gitlink entries persist after purge. ` + + "Aborting checkpoint save.", + ) + } + } + + /** + * Conservative fallback for when ripgrep-based detection failed. Walks + * parent directories of staged paths on the filesystem to discover `.git` + * markers (`.git/HEAD` for real repos, `.git` pointer files for + * submodules/worktrees). Unstages any nested repo roots found and updates + * the exclude list for subsequent saves. Throws if staged paths under a + * nested repo root persist after removal. + * + * Only called when `nestedRepoDetectionFailed` is true, so the filesystem + * walk cost is acceptable — it is the rare error-recovery path. + */ + private async rejectStagedNestedRepoContent(git: SimpleGit) { + const rawOutput = await git.raw(["diff", "--cached", "--name-only", "-z"]) + const stagedPaths = rawOutput.split("\0").filter(Boolean) + + if (stagedPaths.length === 0) { + return + } + + // Walk unique ancestor directories of staged paths, checking for + // .git markers on the filesystem. The checkedDirs cache ensures each + // directory is stat'd at most once regardless of how many staged + // files share it. + const checkedDirs = new Set() + const nestedRepoRoots = new Set() + + for (const filePath of stagedPaths) { + // Staged paths from git are POSIX-style; use path.posix to + // avoid platform-dependent separator drift on Windows. + let dir = path.posix.dirname(filePath) + + while (dir && dir !== "." && dir !== "") { + if (checkedDirs.has(dir)) { + dir = path.posix.dirname(dir) + continue // skip stat calls but keep walking up — an earlier + // traversal may have found a deeper repo and broken out + // before checking this directory's ancestors + } + + checkedDirs.add(dir) + + // Check for .git directory (real nested repo). + // path.join is used here to build filesystem paths for stat calls. + const gitHeadPath = path.join(this.workspaceDir, dir, ".git", "HEAD") + if (await fileExistsAtPath(gitHeadPath)) { + nestedRepoRoots.add(dir) + break + } + + // Check for .git pointer file (submodule / worktree). + const gitFilePath = path.join(this.workspaceDir, dir, ".git") + try { + const stat = await fs.stat(gitFilePath) + if (stat.isFile()) { + const content = await fs.readFile(gitFilePath, "utf8") + if (content.trimStart().startsWith("gitdir:")) { + nestedRepoRoots.add(dir) + break + } + } + } catch { + // Not a .git pointer file — continue walking up. + } + + dir = path.posix.dirname(dir) + } + } + + if (nestedRepoRoots.size === 0) { + return + } + + const roots = Array.from(nestedRepoRoots) + + this.log( + `[${this.constructor.name}#rejectStagedNestedRepoContent] detection fallback found ${roots.length} nested repos via filesystem: ${roots.join(", ")}`, + ) + + // Unstage nested repo contents. + for (const root of roots) { + await git.raw(["rm", "--cached", "--ignore-unmatch", "-r", "--", root + "/"]) + } + + // Update exclude list so subsequent saves are protected. + this.nestedRepoPaths = Array.from(new Set([...this.nestedRepoPaths, ...roots])) + await this.writeExcludeFile() + + // Verify no staged paths remain under nested repo roots. + const verifyRaw = await git.raw(["diff", "--cached", "--name-only", "-z"]) + const verifyPaths = verifyRaw.split("\0").filter(Boolean) + const leaked = verifyPaths.filter((p) => { + const pNorm = p.replace(/\\/g, "/") + return roots.some((r) => pNorm === r || pNorm.startsWith(r + "/")) + }) + + if (leaked.length > 0) { + throw new Error( + `Staging is unsafe: ${leaked.length} paths from nested repos persist after removal. ` + + "Aborting checkpoint save.", ) } } - private async getNestedGitRepository(): Promise { + /** + * Finds all nested git repositories inside the workspace. + * + * Returns workspace-relative POSIX paths (e.g. ["frontend", "packages/foo"]) + * suitable for use in gitignore patterns and pathspec excludes. Detection + * covers both real `.git/` directories and `.git` pointer files used by + * submodules and worktrees. The `--follow` flag is intentionally omitted to + * avoid symlink false positives. + * + * On success, sets `nestedRepoDetectionFailed` to false. On error, sets it + * to true and returns an empty array. When detection has failed, downstream + * safety is enforced by `rejectStagedNestedRepoContent()` in `stageAll()`. + */ + private async findNestedGitRepositories(): Promise { 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" - ) - }) + // Search 1: real nested .git directories via their HEAD file. + const headArgs = ["--files", "--hidden", "-g", "**/.git/HEAD", this.workspaceDir] + const headResults = await executeRipgrep({ args: headArgs, workspacePath: this.workspaceDir }) - 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 + // Search 2: .git pointer files (submodules / worktrees). + const pointerArgs = ["--files", "--hidden", "-g", "**/.git", this.workspaceDir] + const pointerResults = await executeRipgrep({ args: pointerArgs, workspacePath: this.workspaceDir }) - // 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 repoPaths = new Set() - const absolutePath = path.join(this.workspaceDir, repoDir) + // Process .git/HEAD results — each gives us a nested .git directory. + for (const { type, path: filePath } of headResults) { + if (type !== "file") { + continue + } - this.log( - `[${this.constructor.name}#getNestedGitRepository] found ${nestedGitPaths.length} nested git repositories, first at: ${repoDir}`, - ) - return absolutePath + // Skip the workspace root's own .git/HEAD. + const normalized = filePath.replace(/\\/g, "/") + if (normalized === ".git/HEAD") { + continue + } + + // Verify structure: basename must be HEAD, parent must be .git. + if (path.basename(filePath) !== "HEAD" || path.basename(path.dirname(filePath)) !== ".git") { + continue + } + + // .git/HEAD → .git → repo directory + const repoDir = path.dirname(path.dirname(filePath)) + const repoRel = repoDir.replace(/\\/g, "/") + + // Skip if resolved to workspace root or outside workspace. + if (repoRel === "." || repoRel === "" || repoRel.startsWith("..")) { + continue + } + + repoPaths.add(repoRel) } - return null + // Process .git pointer file results — submodules store a file + // containing "gitdir: " instead of a directory. + for (const { type, path: filePath } of pointerResults) { + if (type !== "file") { + continue + } + + // Skip the workspace root's own .git. + const normalized = filePath.replace(/\\/g, "/") + if (normalized === ".git") { + continue + } + + // Only consider entries where the basename is exactly ".git". + if (path.basename(filePath) !== ".git") { + continue + } + + // Validate that this is a pointer file (contains "gitdir:"). + const absPath = path.join(this.workspaceDir, filePath) + try { + const content = await fs.readFile(absPath, "utf8") + if (!content.trimStart().startsWith("gitdir:")) { + continue + } + } catch { + // Can't read the file — skip it. + continue + } + + const repoDir = path.dirname(filePath) + const repoRel = repoDir.replace(/\\/g, "/") + + if (repoRel === "." || repoRel === "" || repoRel.startsWith("..")) { + continue + } + + repoPaths.add(repoRel) + } + + const result = Array.from(repoPaths) + + this.log( + `[${this.constructor.name}#findNestedGitRepositories] found ${result.length} nested git repositories: ${result.join(", ") || "(none)"}`, + ) + + this.nestedRepoDetectionFailed = false + return result } 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}#findNestedGitRepositories] detection failed: ${error instanceof Error ? error.message : String(error)}`, ) - // If we can't check, assume there are no nested repos to avoid blocking the feature. - return null + // Fail-soft: detection failure is not fatal to init, but staging + // will run the conservative filesystem-based scan via + // rejectStagedNestedRepoContent() to prevent unsafe checkpoints. + this.nestedRepoDetectionFailed = true + return [] } } diff --git a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts index ee8f7bbdc9c..22564747b83 100644 --- a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts +++ b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts @@ -378,8 +378,8 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])( }) }) - describe(`${klass.name}#hasNestedGitRepositories`, () => { - it("throws error when nested git repositories are detected during initialization", async () => { + describe(`${klass.name}#nestedGitRepositories`, () => { + it("succeeds and excludes nested repos from checkpoints", 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()}`) @@ -411,37 +411,44 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])( 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] + const searchPattern = args[3] - if (searchPattern.includes(".git/HEAD")) { - // Return the HEAD file path, not the .git directory - const headFilePath = path.join(path.relative(workspaceDir, nestedGitDir), "HEAD") + if (searchPattern === "**/.git/HEAD") { return Promise.resolve([ { - path: headFilePath, - type: "file", // HEAD is a file, not a folder + path: "nested-project/.git/HEAD", + type: "file" as const, label: "HEAD", }, ]) - } else { + } else if (searchPattern === "**/.git") { return Promise.resolve([]) } + + return Promise.resolve([]) }) - const service = new klass(taskId, shadowDir, workspaceDir, () => {}) + const testService = 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:/, - ) + // Initialization should succeed (not throw). + await expect(testService.initShadowGit()).resolves.not.toThrow() + expect(testService.isInitialized).toBe(true) + + // Verify exclude file contains the nested repo pattern. + const excludePath = path.join(shadowDir, ".git", "info", "exclude") + const excludeContent = await fs.readFile(excludePath, "utf-8") + expect(excludeContent).toContain("/nested-project/") + + // Save a checkpoint — nested repo files should not appear in diff. + await fs.writeFile(mainFile, "Modified main content") + const commit = await testService.saveCheckpoint("Checkpoint with nested repo") + expect(commit?.commit).toBeTruthy() + + const diff = await testService.getDiff({ to: commit!.commit }) + const diffPaths = diff.map((d) => d.paths.relative) + expect(diffPaths).toContain("main-file.txt") + expect(diffPaths).not.toContain("nested-project/nested-file.txt") // Clean up. vitest.restoreAllMocks() @@ -468,21 +475,331 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])( 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, () => {}) + const testService = 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) + await expect(testService.initShadowGit()).resolves.not.toThrow() + expect(testService.isInitialized).toBe(true) // Clean up. vitest.restoreAllMocks() await fs.rm(shadowDir, { recursive: true, force: true }) await fs.rm(workspaceDir, { recursive: true, force: true }) }) + + it("detects submodule .git pointer files", async () => { + const shadowDir = path.join(tmpDir, `${prefix}-submodule-${Date.now()}`) + const workspaceDir = path.join(tmpDir, `workspace-submodule-${Date.now()}`) + + 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 submodule-style .git pointer file. + const submodulePath = path.join(workspaceDir, "libs", "shared") + await fs.mkdir(submodulePath, { recursive: true }) + await fs.writeFile( + path.join(submodulePath, ".git"), + "gitdir: ../../.git/modules/libs/shared\n", + ) + await fs.writeFile(path.join(submodulePath, "index.ts"), "export default {}") + + const mainFile = path.join(workspaceDir, "main.ts") + await fs.writeFile(mainFile, "import shared from './libs/shared'") + await mainGit.add(".") + await mainGit.commit("Initial commit") + + vitest.spyOn(fileSearch, "executeRipgrep").mockImplementation(({ args }) => { + const searchPattern = args[3] + + if (searchPattern === "**/.git/HEAD") { + return Promise.resolve([]) + } else if (searchPattern === "**/.git") { + return Promise.resolve([ + { + path: "libs/shared/.git", + type: "file" as const, + label: ".git", + }, + ]) + } + + return Promise.resolve([]) + }) + + const testService = new klass(taskId, shadowDir, workspaceDir, () => {}) + await testService.initShadowGit() + expect(testService.isInitialized).toBe(true) + + // Verify exclude file contains the submodule path. + const excludePath = path.join(shadowDir, ".git", "info", "exclude") + const excludeContent = await fs.readFile(excludePath, "utf-8") + expect(excludeContent).toContain("/libs/shared/") + + // Clean up. + vitest.restoreAllMocks() + await fs.rm(shadowDir, { recursive: true, force: true }) + await fs.rm(workspaceDir, { recursive: true, force: true }) + }) + + it("detects multiple nested repos and excludes all", async () => { + const shadowDir = path.join(tmpDir, `${prefix}-multi-nested-${Date.now()}`) + const workspaceDir = path.join(tmpDir, `workspace-multi-nested-${Date.now()}`) + + 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") + + const mainFile = path.join(workspaceDir, "root.txt") + await fs.writeFile(mainFile, "Root content") + await mainGit.add(".") + await mainGit.commit("Initial commit") + + vitest.spyOn(fileSearch, "executeRipgrep").mockImplementation(({ args }) => { + const searchPattern = args[3] + + if (searchPattern === "**/.git/HEAD") { + return Promise.resolve([ + { path: "frontend/.git/HEAD", type: "file" as const, label: "HEAD" }, + { path: "packages/api/.git/HEAD", type: "file" as const, label: "HEAD" }, + ]) + } else if (searchPattern === "**/.git") { + return Promise.resolve([]) + } + + return Promise.resolve([]) + }) + + const testService = new klass(taskId, shadowDir, workspaceDir, () => {}) + await testService.initShadowGit() + expect(testService.isInitialized).toBe(true) + + // Verify exclude file contains both nested repo patterns. + const excludePath = path.join(shadowDir, ".git", "info", "exclude") + const excludeContent = await fs.readFile(excludePath, "utf-8") + expect(excludeContent).toContain("/frontend/") + expect(excludeContent).toContain("/packages/api/") + + // Clean up. + vitest.restoreAllMocks() + await fs.rm(shadowDir, { recursive: true, force: true }) + await fs.rm(workspaceDir, { recursive: true, force: true }) + }) + + it("no gitlink entries (mode 160000) remain after staging", async () => { + const shadowDir = path.join(tmpDir, `${prefix}-no-gitlink-${Date.now()}`) + const workspaceDir = path.join(tmpDir, `workspace-no-gitlink-${Date.now()}`) + + 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. + const nestedRepoPath = path.join(workspaceDir, "nested") + 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") + await fs.writeFile(path.join(nestedRepoPath, "file.txt"), "nested content") + await nestedGit.add(".") + await nestedGit.commit("nested commit") + + await fs.writeFile(path.join(workspaceDir, "root.txt"), "root content") + await mainGit.add(".") + await mainGit.commit("Initial commit") + + vitest.spyOn(fileSearch, "executeRipgrep").mockImplementation(({ args }) => { + const searchPattern = args[3] + + if (searchPattern === "**/.git/HEAD") { + return Promise.resolve([ + { path: "nested/.git/HEAD", type: "file" as const, label: "HEAD" }, + ]) + } else if (searchPattern === "**/.git") { + return Promise.resolve([]) + } + + return Promise.resolve([]) + }) + + const testService = new klass(taskId, shadowDir, workspaceDir, () => {}) + await testService.initShadowGit() + + // Save a checkpoint and then inspect the shadow git index. + await fs.writeFile(path.join(workspaceDir, "root.txt"), "modified root") + await testService.saveCheckpoint("Checkpoint") + + // Inspect the shadow repo's index for gitlink entries. + const lsOutput = await simpleGit(shadowDir).raw(["ls-files", "--stage"]) + const gitlinkLines = lsOutput.split("\n").filter((l: string) => l.startsWith("160000 ")) + expect(gitlinkLines).toHaveLength(0) + + // Clean up. + vitest.restoreAllMocks() + await fs.rm(shadowDir, { recursive: true, force: true }) + await fs.rm(workspaceDir, { recursive: true, force: true }) + }) + + it("restore does not touch nested repo directory", async () => { + const shadowDir = path.join(tmpDir, `${prefix}-restore-nested-${Date.now()}`) + const workspaceDir = path.join(tmpDir, `workspace-restore-nested-${Date.now()}`) + + 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 with content. + const nestedRepoPath = path.join(workspaceDir, "nested") + 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") + const nestedFile = path.join(nestedRepoPath, "nested-file.txt") + await fs.writeFile(nestedFile, "Original nested content") + await nestedGit.add(".") + await nestedGit.commit("nested commit") + + // Create a main workspace file. + const mainFile = path.join(workspaceDir, "main.txt") + await fs.writeFile(mainFile, "Original main content") + await mainGit.add(".") + await mainGit.commit("Initial commit") + + vitest.spyOn(fileSearch, "executeRipgrep").mockImplementation(({ args }) => { + const searchPattern = args[3] + + if (searchPattern === "**/.git/HEAD") { + return Promise.resolve([ + { path: "nested/.git/HEAD", type: "file" as const, label: "HEAD" }, + ]) + } else if (searchPattern === "**/.git") { + return Promise.resolve([]) + } + + return Promise.resolve([]) + }) + + const testService = new klass(taskId, shadowDir, workspaceDir, () => {}) + await testService.initShadowGit() + + // Save checkpoint 1. + await fs.writeFile(mainFile, "Modified main content") + const commit1 = await testService.saveCheckpoint("Checkpoint 1") + expect(commit1?.commit).toBeTruthy() + + // Modify nested repo file (should not be tracked). + await fs.writeFile(nestedFile, "Modified nested content") + + // Restore to checkpoint 1 — nested repo should be untouched. + await testService.restoreCheckpoint(commit1!.commit) + expect(await fs.readFile(mainFile, "utf-8")).toBe("Modified main content") + // Nested file retains its modification since it's excluded. + expect(await fs.readFile(nestedFile, "utf-8")).toBe("Modified nested content") + expect(await fileExistsAtPath(path.join(nestedRepoPath, ".git"))).toBe(true) + + // Clean up. + vitest.restoreAllMocks() + await fs.rm(shadowDir, { recursive: true, force: true }) + await fs.rm(workspaceDir, { recursive: true, force: true }) + }) + + it("aborts checkpoint save when detection failed and nested repo content is staged", async () => { + const shadowDir = path.join(tmpDir, `${prefix}-detect-fail-${Date.now()}`) + const workspaceDir = path.join(tmpDir, `workspace-detect-fail-${Date.now()}`) + + 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. + const nestedRepoPath = path.join(workspaceDir, "nested") + 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") + await fs.writeFile(path.join(nestedRepoPath, "file.txt"), "nested content") + await nestedGit.add(".") + await nestedGit.commit("nested commit") + + await fs.writeFile(path.join(workspaceDir, "root.txt"), "root content") + await mainGit.add(".") + await mainGit.commit("Initial commit") + + // Simulate detection failure — ripgrep throws. + vitest.spyOn(fileSearch, "executeRipgrep").mockRejectedValue( + new Error("ripgrep not found"), + ) + + const logMessages: string[] = [] + const testService = new klass(taskId, shadowDir, workspaceDir, (msg: string) => + logMessages.push(msg), + ) + await testService.initShadowGit() + expect(testService.isInitialized).toBe(true) + + // Verify detection failure was logged. + expect(logMessages.some((m) => m.includes("detection failed"))).toBe(true) + + // Attempting to save a checkpoint should trigger the + // conservative fallback and either safely exclude nested + // content or abort. + await fs.writeFile(path.join(workspaceDir, "root.txt"), "modified root") + + // The save may succeed (if the fallback successfully removes + // nested content) or throw (if nested content persists). + // Either way, the checkpoint must not contain nested repo files. + try { + const commit = await testService.saveCheckpoint("Fallback test") + if (commit?.commit) { + const diff = await testService.getDiff({ to: commit.commit }) + const diffPaths = diff.map((d) => d.paths.relative) + expect(diffPaths).not.toContain("nested/file.txt") + } + } catch (error) { + // Safety abort is acceptable — it means the fallback + // detected unsafe content and refused to proceed. + expect(error).toBeInstanceOf(Error) + } + + // Clean up. + vitest.restoreAllMocks() + await fs.rm(shadowDir, { recursive: true, force: true }) + await fs.rm(workspaceDir, { recursive: true, force: true }) + }) + + it("aborts checkpoint save on real git add failure (not ignored-paths warning)", async () => { + // Ensure real staging failures (index corruption, disk full, etc.) + // still abort the save rather than producing a bad checkpoint. + await fs.writeFile(testFile, "Content that should not be checkpointed") + + // Mock git.add to throw a real error (not an "ignored paths" warning). + const addSpy = vitest.spyOn(service["git"]!, "add").mockRejectedValueOnce( + new Error("fatal: index file corrupt"), + ) + + await expect( + service.saveCheckpoint("Should fail"), + ).rejects.toThrow("fatal: index file corrupt") + + // Verify the staging path was actually exercised. + expect(addSpy).toHaveBeenCalled() + addSpy.mockRestore() + }) }) describe(`${klass.name}#events`, () => { From d4b7e1c3353b4143fd60354c7806e926a833a559 Mon Sep 17 00:00:00 2001 From: 0xMink Date: Mon, 9 Feb 2026 20:26:11 -0500 Subject: [PATCH 2/2] i18n: add nested_git_repos_excluded across locales --- src/i18n/locales/ca/common.json | 1 + src/i18n/locales/de/common.json | 1 + src/i18n/locales/es/common.json | 1 + src/i18n/locales/fr/common.json | 1 + src/i18n/locales/hi/common.json | 1 + src/i18n/locales/id/common.json | 1 + src/i18n/locales/it/common.json | 1 + src/i18n/locales/ja/common.json | 1 + src/i18n/locales/ko/common.json | 1 + src/i18n/locales/nl/common.json | 1 + src/i18n/locales/pl/common.json | 1 + src/i18n/locales/pt-BR/common.json | 1 + src/i18n/locales/ru/common.json | 1 + src/i18n/locales/tr/common.json | 1 + src/i18n/locales/vi/common.json | 1 + src/i18n/locales/zh-CN/common.json | 1 + src/i18n/locales/zh-TW/common.json | 1 + 17 files changed, 17 insertions(+) diff --git a/src/i18n/locales/ca/common.json b/src/i18n/locales/ca/common.json index 33188fce193..f83bbcad2b9 100644 --- a/src/i18n/locales/ca/common.json +++ b/src/i18n/locales/ca/common.json @@ -39,6 +39,7 @@ "checkpoint_diff_with_next": "Canvis comparats amb el següent punt de control", "checkpoint_diff_since_first": "Canvis des del primer punt de control", "checkpoint_diff_to_current": "Canvis a l'espai de treball actual", + "nested_git_repos_excluded": "Punts de control activats. S'han exclòs {{count}} repositoris git niats: {{paths}}.", "nested_git_repos_warning": "Els punts de control estan deshabilitats perquè s'ha detectat un repositori git niat a: {{path}}. Per utilitzar punts de control, si us plau elimina o reubica aquest repositori git niat.", "no_workspace": "Si us plau, obre primer una carpeta de projecte", "update_support_prompt": "Ha fallat l'actualització del missatge de suport", diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index 861d9da5768..c5c7bc30881 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -35,6 +35,7 @@ "checkpoint_diff_with_next": "Änderungen im Vergleich zum nächsten Checkpoint", "checkpoint_diff_since_first": "Änderungen seit dem ersten Checkpoint", "checkpoint_diff_to_current": "Änderungen am aktuellen Arbeitsbereich", + "nested_git_repos_excluded": "Checkpoints aktiviert. {{count}} verschachtelte Git-Repositorys ausgeschlossen: {{paths}}.", "nested_git_repos_warning": "Checkpoints sind deaktiviert, da ein verschachteltes Git-Repository erkannt wurde unter: {{path}}. Um Checkpoints zu verwenden, entferne oder verschiebe bitte dieses verschachtelte Git-Repository.", "no_workspace": "Bitte öffne zuerst einen Projektordner", "update_support_prompt": "Fehler beim Aktualisieren der Support-Nachricht", diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index 82be83956b0..90d0aca8361 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -35,6 +35,7 @@ "checkpoint_diff_with_next": "Cambios comparados con el siguiente punto de control", "checkpoint_diff_since_first": "Cambios desde el primer punto de control", "checkpoint_diff_to_current": "Cambios en el espacio de trabajo actual", + "nested_git_repos_excluded": "Puntos de control habilitados. Se excluyeron {{count}} repositorios git anidados: {{paths}}.", "nested_git_repos_warning": "Los puntos de control están deshabilitados porque se detectó un repositorio git anidado en: {{path}}. Para usar puntos de control, por favor elimina o reubica este repositorio git anidado.", "no_workspace": "Por favor, abre primero una carpeta de proyecto", "update_support_prompt": "Error al actualizar el mensaje de soporte", diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index 6fc05ff94a3..79a11b4959f 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -35,6 +35,7 @@ "checkpoint_diff_with_next": "Modifications comparées au prochain point de contrôle", "checkpoint_diff_since_first": "Modifications depuis le premier point de contrôle", "checkpoint_diff_to_current": "Modifications de l'espace de travail actuel", + "nested_git_repos_excluded": "Points de contrôle activés. {{count}} dépôts git imbriqués exclus : {{paths}}.", "nested_git_repos_warning": "Les points de contrôle sont désactivés car un dépôt git imbriqué a été détecté à : {{path}}. Pour utiliser les points de contrôle, veuillez supprimer ou déplacer ce dépôt git imbriqué.", "no_workspace": "Veuillez d'abord ouvrir un espace de travail", "update_support_prompt": "Erreur lors de la mise à jour du prompt de support", diff --git a/src/i18n/locales/hi/common.json b/src/i18n/locales/hi/common.json index 528ed6d45f5..23eea21c42f 100644 --- a/src/i18n/locales/hi/common.json +++ b/src/i18n/locales/hi/common.json @@ -35,6 +35,7 @@ "checkpoint_diff_with_next": "अगले चेकपॉइंट के साथ तुलना किए गए बदलाव", "checkpoint_diff_since_first": "पहले चेकपॉइंट के बाद से बदलाव", "checkpoint_diff_to_current": "वर्तमान कार्यक्षेत्र में बदलाव", + "nested_git_repos_excluded": "चेकपॉइंट सक्षम। {{count}} नेस्टेड git रिपॉजिटरी बहिष्कृत: {{paths}}।", "nested_git_repos_warning": "चेकपॉइंट अक्षम हैं क्योंकि {{path}} पर नेस्टेड git रिपॉजिटरी का पता चला है। चेकपॉइंट का उपयोग करने के लिए, कृपया इस नेस्टेड git रिपॉजिटरी को हटाएं या स्थानांतरित करें।", "no_workspace": "कृपया पहले प्रोजेक्ट फ़ोल्डर खोलें", "update_support_prompt": "सपोर्ट प्रॉम्प्ट अपडेट करने में विफल", diff --git a/src/i18n/locales/id/common.json b/src/i18n/locales/id/common.json index cb1c3231fb8..f1e91a1599a 100644 --- a/src/i18n/locales/id/common.json +++ b/src/i18n/locales/id/common.json @@ -35,6 +35,7 @@ "checkpoint_diff_with_next": "Perubahan dibandingkan dengan checkpoint berikutnya", "checkpoint_diff_since_first": "Perubahan sejak checkpoint pertama", "checkpoint_diff_to_current": "Perubahan ke ruang kerja saat ini", + "nested_git_repos_excluded": "Checkpoint diaktifkan. {{count}} repositori git bersarang dikecualikan: {{paths}}.", "nested_git_repos_warning": "Checkpoint dinonaktifkan karena repositori git bersarang terdeteksi di: {{path}}. Untuk menggunakan checkpoint, silakan hapus atau pindahkan repositori git bersarang ini.", "no_workspace": "Silakan buka folder proyek terlebih dahulu", "update_support_prompt": "Gagal memperbarui support prompt", diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index b4e522cb732..e97b112b08b 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -35,6 +35,7 @@ "checkpoint_diff_with_next": "Modifiche confrontate con il checkpoint successivo", "checkpoint_diff_since_first": "Modifiche dal primo checkpoint", "checkpoint_diff_to_current": "Modifiche all'area di lavoro corrente", + "nested_git_repos_excluded": "Checkpoint abilitati. Esclusi {{count}} repository git annidati: {{paths}}.", "nested_git_repos_warning": "I checkpoint sono disabilitati perché è stato rilevato un repository git annidato in: {{path}}. Per utilizzare i checkpoint, rimuovi o sposta questo repository git annidato.", "no_workspace": "Per favore, apri prima una cartella di progetto", "update_support_prompt": "Errore durante l'aggiornamento del messaggio di supporto", diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index 7b63b6f7298..aa7194c2ac4 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -35,6 +35,7 @@ "checkpoint_diff_with_next": "次のチェックポイントと比較した変更点", "checkpoint_diff_since_first": "最初のチェックポイントからの変更点", "checkpoint_diff_to_current": "現在のワークスペースへの変更点", + "nested_git_repos_excluded": "チェックポイントが有効です。{{count}} 個のネストされたgitリポジトリを除外しました: {{paths}}。", "nested_git_repos_warning": "{{path}} でネストされたgitリポジトリが検出されたため、チェックポイントが無効になっています。チェックポイントを使用するには、このネストされたgitリポジトリを削除または移動してください。", "no_workspace": "まずプロジェクトフォルダを開いてください", "update_support_prompt": "サポートメッセージの更新に失敗しました", diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index fbde3225bb1..27d1baa8f32 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -35,6 +35,7 @@ "checkpoint_diff_with_next": "다음 체크포인트와 비교한 변경 사항", "checkpoint_diff_since_first": "첫 번째 체크포인트 이후의 변경 사항", "checkpoint_diff_to_current": "현재 작업 공간으로의 변경 사항", + "nested_git_repos_excluded": "체크포인트가 활성화되었습니다. {{count}}개의 중첩된 git 저장소를 제외했습니다: {{paths}}.", "nested_git_repos_warning": "{{path}}에서 중첩된 git 저장소가 감지되어 체크포인트가 비활성화되었습니다. 체크포인트를 사용하려면 이 중첩된 git 저장소를 제거하거나 이동해주세요.", "no_workspace": "먼저 프로젝트 폴더를 열어주세요", "update_support_prompt": "지원 프롬프트 업데이트에 실패했습니다", diff --git a/src/i18n/locales/nl/common.json b/src/i18n/locales/nl/common.json index eba274c96ec..51ae0aeefed 100644 --- a/src/i18n/locales/nl/common.json +++ b/src/i18n/locales/nl/common.json @@ -35,6 +35,7 @@ "checkpoint_diff_with_next": "Wijzigingen vergeleken met volgend checkpoint", "checkpoint_diff_since_first": "Wijzigingen sinds eerste checkpoint", "checkpoint_diff_to_current": "Wijzigingen in huidige werkruimte", + "nested_git_repos_excluded": "Checkpoints ingeschakeld. {{count}} geneste git-repositories uitgesloten: {{paths}}.", "nested_git_repos_warning": "Checkpoints zijn uitgeschakeld omdat een geneste git-repository is gedetecteerd op: {{path}}. Om checkpoints te gebruiken, verwijder of verplaats deze geneste git-repository.", "no_workspace": "Open eerst een projectmap", "update_support_prompt": "Bijwerken van ondersteuningsprompt mislukt", diff --git a/src/i18n/locales/pl/common.json b/src/i18n/locales/pl/common.json index 20b568281bb..0f766bb55df 100644 --- a/src/i18n/locales/pl/common.json +++ b/src/i18n/locales/pl/common.json @@ -35,6 +35,7 @@ "checkpoint_diff_with_next": "Zmiany w porównaniu z następnym punktem kontrolnym", "checkpoint_diff_since_first": "Zmiany od pierwszego punktu kontrolnego", "checkpoint_diff_to_current": "Zmiany w bieżącym obszarze roboczym", + "nested_git_repos_excluded": "Punkty kontrolne włączone. Wykluczono {{count}} zagnieżdżonych repozytoriów git: {{paths}}.", "nested_git_repos_warning": "Punkty kontrolne są wyłączone, ponieważ wykryto zagnieżdżone repozytorium git w: {{path}}. Aby używać punktów kontrolnych, usuń lub przenieś to zagnieżdżone repozytorium git.", "no_workspace": "Najpierw otwórz folder projektu", "update_support_prompt": "Nie udało się zaktualizować komunikatu wsparcia", diff --git a/src/i18n/locales/pt-BR/common.json b/src/i18n/locales/pt-BR/common.json index 38abc8c8047..936a326e92e 100644 --- a/src/i18n/locales/pt-BR/common.json +++ b/src/i18n/locales/pt-BR/common.json @@ -39,6 +39,7 @@ "checkpoint_diff_with_next": "Alterações comparadas com o próximo ponto de verificação", "checkpoint_diff_since_first": "Alterações desde o primeiro ponto de verificação", "checkpoint_diff_to_current": "Alterações no espaço de trabalho atual", + "nested_git_repos_excluded": "Checkpoints habilitados. {{count}} repositórios git aninhados excluídos: {{paths}}.", "nested_git_repos_warning": "Os checkpoints estão desabilitados porque um repositório git aninhado foi detectado em: {{path}}. Para usar checkpoints, por favor remova ou realoque este repositório git aninhado.", "no_workspace": "Por favor, abra primeiro uma pasta de projeto", "update_support_prompt": "Falha ao atualizar o prompt de suporte", diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index d124f597318..1c908e10f43 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -35,6 +35,7 @@ "checkpoint_diff_with_next": "Изменения по сравнению со следующей контрольной точкой", "checkpoint_diff_since_first": "Изменения с первой контрольной точки", "checkpoint_diff_to_current": "Изменения в текущем рабочем пространстве", + "nested_git_repos_excluded": "Контрольные точки включены. Исключено {{count}} вложенных git-репозиториев: {{paths}}.", "nested_git_repos_warning": "Контрольные точки отключены, поскольку обнаружен вложенный git-репозиторий в: {{path}}. Чтобы использовать контрольные точки, пожалуйста, удалите или переместите этот вложенный git-репозиторий.", "no_workspace": "Пожалуйста, сначала откройте папку проекта", "update_support_prompt": "Не удалось обновить промпт поддержки", diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index 00dcf6fc33d..d73ce085931 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -35,6 +35,7 @@ "checkpoint_diff_with_next": "Sonraki kontrol noktasıyla karşılaştırılan değişiklikler", "checkpoint_diff_since_first": "İlk kontrol noktasından bu yana yapılan değişiklikler", "checkpoint_diff_to_current": "Mevcut çalışma alanındaki değişiklikler", + "nested_git_repos_excluded": "Kontrol noktaları etkinleştirildi. {{count}} iç içe git deposu hariç tutuldu: {{paths}}.", "nested_git_repos_warning": "{{path}} konumunda iç içe git deposu tespit edildiği için kontrol noktaları devre dışı bırakıldı. Kontrol noktalarını kullanmak için lütfen bu iç içe git deposunu kaldırın veya taşıyın.", "no_workspace": "Lütfen önce bir proje klasörü açın", "update_support_prompt": "Destek istemi güncellenemedi", diff --git a/src/i18n/locales/vi/common.json b/src/i18n/locales/vi/common.json index decd4ff53ef..92f49e0c328 100644 --- a/src/i18n/locales/vi/common.json +++ b/src/i18n/locales/vi/common.json @@ -35,6 +35,7 @@ "checkpoint_diff_with_next": "Các thay đổi được so sánh với điểm kiểm tra tiếp theo", "checkpoint_diff_since_first": "Các thay đổi kể từ điểm kiểm tra đầu tiên", "checkpoint_diff_to_current": "Các thay đổi đối với không gian làm việc hiện tại", + "nested_git_repos_excluded": "Điểm kiểm tra đã bật. Đã loại trừ {{count}} kho git lồng nhau: {{paths}}.", "nested_git_repos_warning": "Điểm kiểm tra bị vô hiệu hóa vì phát hiện kho git lồng nhau tại: {{path}}. Để sử dụng điểm kiểm tra, vui lòng xóa hoặc di chuyển kho git lồng nhau này.", "no_workspace": "Vui lòng mở thư mục dự án trước", "update_support_prompt": "Không thể cập nhật lời nhắc hỗ trợ", diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index 6df1f78b167..27b25185c2f 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -40,6 +40,7 @@ "checkpoint_diff_with_next": "与下一个存档点比较的更改", "checkpoint_diff_since_first": "自第一个存档点以来的更改", "checkpoint_diff_to_current": "对当前工作区的更改", + "nested_git_repos_excluded": "存档点已启用。已排除 {{count}} 个嵌套的 git 仓库:{{paths}}。", "nested_git_repos_warning": "存档点已禁用,因为在 {{path}} 检测到嵌套的 git 仓库。要使用存档点,请移除或重新定位此嵌套的 git 仓库。", "no_workspace": "请先打开项目文件夹", "update_support_prompt": "更新支持消息失败", diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json index be4a76fc5b9..7a1067ca669 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -35,6 +35,7 @@ "checkpoint_diff_with_next": "與下一個存檔點比較的變更", "checkpoint_diff_since_first": "自第一個存檔點以來的變更", "checkpoint_diff_to_current": "對目前工作區的變更", + "nested_git_repos_excluded": "存檔點已啟用。已排除 {{count}} 個巢狀的 git 儲存庫:{{paths}}。", "nested_git_repos_warning": "存檔點已停用,因為在 {{path}} 偵測到巢狀的 git 儲存庫。要使用存檔點,請移除或重新配置此巢狀的 git 儲存庫。", "no_workspace": "請先開啟專案資料夾", "update_support_prompt": "更新支援訊息失敗",