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/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/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": "更新支援訊息失敗", 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`, () => {