diff --git a/src/lib/GitWorktreeCleanup.svelte b/src/lib/GitWorktreeCleanup.svelte index 0c45e040e..75b689edb 100644 --- a/src/lib/GitWorktreeCleanup.svelte +++ b/src/lib/GitWorktreeCleanup.svelte @@ -2,18 +2,32 @@ import { confirm, open } from "@tauri-apps/plugin-dialog"; import * as api from "./api"; import { fmtBytes } from "./fmt"; + import { + GIT_WORKTREE_AUDIT_FAILURE, + GIT_WORKTREE_CONFIRMATION_FAILURE, + GIT_WORKTREE_REMOVAL_FAILURE, + GIT_WORKTREE_REPOSITORY_SELECTION_FAILURE, + GIT_WORKTREE_RESULT_RECORD_FAILURE, + evidenceGapActions, + removalStoppedAction, + } from "./gitWorktreeFeedback"; let { scannedRoot }: { scannedRoot: string | null } = $props(); let repositoryRoot = $state(""); let retentionText = $state(""); let planning = $state(false); + let choosing = $state(false); + let confirming = $state(false); let executing = $state(false); let error = $state(""); let report: api.GitWorktreeAuditReport | null = $state(null); let confirmationPhrase = $state(""); let rationale = $state(""); let removal: api.StaleGitWorktreeRemovalOutput | null = $state(null); + let selectionSeq = 0; + let auditSeq = 0; + let removalSeq = 0; $effect(() => { if (!repositoryRoot && scannedRoot) repositoryRoot = scannedRoot; @@ -45,6 +59,8 @@ } async function chooseRepository() { + const seq = ++selectionSeq; + choosing = true; error = ""; try { const selected = await open({ @@ -53,11 +69,13 @@ defaultPath: repositoryRoot || scannedRoot || undefined, title: "Git 저장소 또는 연결된 worktree 선택", }); - if (typeof selected !== "string") return; + if (seq !== selectionSeq || typeof selected !== "string") return; repositoryRoot = selected; resetDecision(); - } catch (e) { - error = String(e); + } catch { + if (seq === selectionSeq) error = GIT_WORKTREE_REPOSITORY_SELECTION_FAILURE; + } finally { + if (seq === selectionSeq) choosing = false; } } @@ -67,16 +85,19 @@ if (!root || references.length === 0) return; planning = true; resetDecision(); + const seq = ++auditSeq; try { - report = await api.planStaleGitWorktrees(root, references); - repositoryRoot = report.repository_root; - retentionText = report.retention_references + const nextReport = await api.planStaleGitWorktrees(root, references); + if (seq !== auditSeq) return; + report = nextReport; + repositoryRoot = nextReport.repository_root; + retentionText = nextReport.retention_references .map((binding) => binding.reference_ref) .join("\n"); - } catch (e) { - error = String(e); + } catch { + if (seq === auditSeq) error = GIT_WORKTREE_AUDIT_FAILURE; } finally { - planning = false; + if (seq === auditSeq) planning = false; } } @@ -87,34 +108,49 @@ && report.exact_approval_phrase !== null && confirmationPhrase === report.exact_approval_phrase && rationale.trim().length > 0 + && !confirming && !executing && removal === null; } async function removeWorktrees() { if (!report || !executionReady()) return; - const approved = await confirm( - `${report.removal_candidate_count}개 worktree 디렉터리(최대 ${fmtBytes(report.removal_candidate_allocated_bytes)})를 제거합니다.\n\n` - + "각 항목은 실행 직전에 다시 검사합니다. 브랜치와 커밋은 유지하며 force·prune은 사용하지 않습니다. 제거된 디렉터리는 휴지통으로 가지 않습니다.", - { title: "DiskSage 오래된 Git worktree 제거", kind: "warning" }, - ); - if (!approved) return; - executing = true; - error = ""; + const approvedReport = report; + const approvedPhrase = confirmationPhrase; + const approvedRationale = rationale.trim(); + const seq = ++removalSeq; + confirming = true; try { - removal = await api.removeStaleGitWorktrees( - report.repository_root, - report.retention_references.map((binding) => binding.reference_ref), - report.removal_plan_fingerprint, - confirmationPhrase, - rationale.trim(), + const approved = await confirm( + `${approvedReport.removal_candidate_count}개 worktree 디렉터리(최대 ${fmtBytes(approvedReport.removal_candidate_allocated_bytes)})를 제거합니다.\n\n` + + "각 항목은 실행 직전에 다시 검사합니다. 브랜치와 커밋은 유지하며 force·prune은 사용하지 않습니다. 제거된 디렉터리는 휴지통으로 가지 않습니다.", + { title: "DiskSage 오래된 Git worktree 제거", kind: "warning" }, ); - confirmationPhrase = ""; - rationale = ""; - } catch (e) { - error = String(e); + if (!approved || seq !== removalSeq || report !== approvedReport) return; + executing = true; + error = ""; + try { + removal = await api.removeStaleGitWorktrees( + approvedReport.repository_root, + approvedReport.retention_references.map((binding) => binding.reference_ref), + approvedReport.removal_plan_fingerprint, + approvedPhrase, + approvedRationale, + ); + confirmationPhrase = ""; + rationale = ""; + } catch { + report = null; + confirmationPhrase = ""; + rationale = ""; + error = GIT_WORKTREE_REMOVAL_FAILURE; + } finally { + executing = false; + } + } catch { + error = GIT_WORKTREE_CONFIRMATION_FAILURE; } finally { - executing = false; + if (seq === removalSeq) confirming = false; } } @@ -135,10 +171,12 @@ oninput={resetDecision} autocomplete="off" spellcheck="false" - disabled={planning || executing} + disabled={choosing || planning || confirming || executing} /> - + @@ -185,10 +223,17 @@ {#if evidenceGapEntries().length > 0}
- 증거가 부족해 전체 실행을 차단했습니다. + 증거가 부족해 전체 실행을 차단했습니다. 다음 항목을 확인하세요.
@@ -201,18 +246,16 @@ 사전 할당량 기준 최대 {fmtBytes(removal.result.removed_allocated_bytes_upper_bound)}입니다.

{:else} -

- 일부 또는 사후 검증이 완료되지 않았습니다: {removal.result.stopped_reason ?? "검증 불완전"}. +

{/if} -

승인 기록: {removal.approval_path}

+

승인 기록을 DiskSage 데이터 폴더에 저장했습니다.

{#if removal.result_path} -

결과 기록: {removal.result_path}

+

결과 기록을 DiskSage 데이터 폴더에 저장했습니다.

{:else} - + {/if} {:else if report.evidence_complete && report.exact_approval_phrase}
@@ -240,7 +283,7 @@ >
{:else if report.removal_candidate_count === 0} @@ -265,7 +308,8 @@ .worktrees li { padding: 0.45rem 0; border-bottom: 1px solid #d9e0e6; } .path { overflow-wrap: anywhere; color: #66717d; font-size: 0.78rem; } .blocked { padding: 0.6rem; border: 1px solid #b74a4a; background: #fff6f6; } - .blocked ul { margin-bottom: 0; } + .blocked > ul { margin-bottom: 0; } + .evidence-actions { margin: 0.25rem 0 0; } .approval { display: grid; gap: 0.55rem; justify-items: start; padding: 0.7rem; border: 1px solid #b78335; border-radius: 4px; background: #fffaf1; } .approval code { max-width: min(60rem, 90vw); overflow-wrap: anywhere; user-select: all; } .approval textarea { width: min(60rem, 90vw); resize: vertical; } diff --git a/src/lib/gitWorktreeErrorPrivacyContract.test.ts b/src/lib/gitWorktreeErrorPrivacyContract.test.ts new file mode 100644 index 000000000..340f5e654 --- /dev/null +++ b/src/lib/gitWorktreeErrorPrivacyContract.test.ts @@ -0,0 +1,170 @@ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { + GIT_WORKTREE_AUDIT_FAILURE, + GIT_WORKTREE_CONFIRMATION_FAILURE, + GIT_WORKTREE_REMOVAL_FAILURE, + GIT_WORKTREE_REPOSITORY_SELECTION_FAILURE, + GIT_WORKTREE_RESULT_RECORD_FAILURE, + evidenceGapActions, + removalStoppedAction, +} from "./gitWorktreeFeedback"; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); + +function readSource(path: string): string { + return readFileSync(resolve(repositoryRoot, path), "utf8"); +} + +describe("Git worktree privacy-safe failure feedback", () => { + it("never renders arbitrary thrown or record-persistence exception text", () => { + const source = readSource("src/lib/GitWorktreeCleanup.svelte"); + + expect(source).not.toContain("String(e)"); + expect(source).not.toContain("catch (e)"); + expect(source).not.toContain("{removal.result_record_error}"); + expect(source).toContain("GIT_WORKTREE_REPOSITORY_SELECTION_FAILURE"); + expect(source).toContain("GIT_WORKTREE_AUDIT_FAILURE"); + expect(source).toContain("GIT_WORKTREE_REMOVAL_FAILURE"); + expect(source).toContain("GIT_WORKTREE_RESULT_RECORD_FAILURE"); + }); + + it("does not disclose immutable record paths in the desktop UI", () => { + const source = readSource("src/lib/GitWorktreeCleanup.svelte"); + + expect(source).not.toContain("승인 기록: {removal.approval_path}"); + expect(source).not.toContain("결과 기록: {removal.result_path}"); + expect(source).toContain("승인 기록을 DiskSage 데이터 폴더에 저장했습니다."); + expect(source).toContain("결과 기록을 DiskSage 데이터 폴더에 저장했습니다."); + expect(source).toContain("{#if removal.result_path}"); + expect(source).toContain("GIT_WORKTREE_RESULT_RECORD_FAILURE"); + }); + + it("uses path-free failure copy that directs the next safe action", () => { + expect(GIT_WORKTREE_REPOSITORY_SELECTION_FAILURE).toBe( + "Git 저장소를 선택하지 못했습니다. 폴더 접근 권한과 저장소 위치를 확인한 뒤 다시 선택하세요.", + ); + expect(GIT_WORKTREE_CONFIRMATION_FAILURE).toBe( + "제거 확인 창을 열지 못했습니다. 다른 확인 창을 닫은 뒤 새 감사부터 다시 진행하세요.", + ); + expect(GIT_WORKTREE_AUDIT_FAILURE).toBe( + "Git worktree 감사를 완료하지 못했습니다. 저장소 경로와 보존할 ref가 현재 로컬에서 해석되는지 확인한 뒤 다시 감사하세요.", + ); + expect(GIT_WORKTREE_REMOVAL_FAILURE).toBe( + "Git worktree 제거에 실패했습니다. 저장소 상태와 계획 지문을 다시 확인한 뒤 새 감사부터 진행하세요.", + ); + expect(GIT_WORKTREE_RESULT_RECORD_FAILURE).toBe( + "제거 결과는 위와 같지만 기록을 저장하지 못했습니다. DiskSage 데이터 폴더의 권한과 여유 공간을 확인하고 이 화면의 결과를 별도로 보관하세요.", + ); + + for (const message of [ + GIT_WORKTREE_REPOSITORY_SELECTION_FAILURE, + GIT_WORKTREE_CONFIRMATION_FAILURE, + GIT_WORKTREE_AUDIT_FAILURE, + GIT_WORKTREE_REMOVAL_FAILURE, + GIT_WORKTREE_RESULT_RECORD_FAILURE, + ]) { + expect(message).not.toMatch(/(?:\/Users\/|[A-Za-z]:\\|file:\/\/)/); + expect(message).toMatch(/(?:확인|보관|선택|감사)/); + } + }); + + it("serializes dialogs and discards superseded async results", () => { + const source = readSource("src/lib/GitWorktreeCleanup.svelte"); + + expect(source).toContain("const seq = ++selectionSeq;"); + expect(source).toContain("if (seq !== selectionSeq || typeof selected !== \"string\") return;"); + expect(source).toContain("const seq = ++auditSeq;"); + expect(source).toContain("if (seq !== auditSeq) return;"); + expect(source).toContain("const seq = ++removalSeq;"); + expect(source).toContain("if (!approved || seq !== removalSeq || report !== approvedReport) return;"); + expect(source).toContain("disabled={choosing || planning || confirming || executing}"); + expect(source).toContain("report = null;"); + }); + + it("turns every current evidence gap into bounded customer guidance", () => { + const cases: Array<[string, string]> = [ + [ + "worktree-path-evidence-incomplete", + "worktree 경로를 안전하게 확인하지 못했습니다. Git 등록 상태와 디렉터리 접근 권한을 확인한 뒤 다시 감사하세요.", + ], + [ + "git-status-evidence-incomplete", + "변경 사항 여부를 확인하지 못했습니다. 해당 worktree에서 Git 상태를 확인한 뒤 다시 감사하세요.", + ], + [ + "reference-containment-evidence-incomplete", + "HEAD가 보존 ref에 포함되는지 확인하지 못했습니다. ref를 fetch하고 정확한 ref 이름을 입력한 뒤 다시 감사하세요.", + ], + [ + "actor-cwd-evidence-incomplete", + "현재 디렉터리 사용 여부를 확인하지 못했습니다. DiskSage와 터미널의 현재 디렉터리를 worktree 밖으로 옮긴 뒤 다시 감사하세요.", + ], + [ + "size-evidence-incomplete", + "worktree 크기를 끝까지 측정하지 못했습니다. 접근 권한과 디스크 상태를 확인한 뒤 다시 감사하세요.", + ], + [ + "active-use-evidence-incomplete", + "열린 파일과 프로세스 사용 여부를 확인하지 못했습니다. 관련 앱과 터미널을 닫은 뒤 다시 감사하세요.", + ], + ]; + + for (const [code, action] of cases) { + expect(evidenceGapActions([code])).toEqual([action]); + } + }); + + it("deduplicates evidence guidance and never reflects unknown blocker text", () => { + const fallback = + "증거가 불완전합니다. 해당 worktree의 Git 상태와 활성 사용을 직접 확인한 뒤 다시 감사하세요."; + const injected = "/Users/example/private-worktree: probe failed"; + expect(evidenceGapActions([injected])).toEqual([fallback]); + expect(evidenceGapActions([injected]).join(" ")).not.toContain(injected); + expect(evidenceGapActions([])).toEqual([fallback]); + expect( + evidenceGapActions(["active-use-evidence-incomplete", "active-use-evidence-incomplete"]), + ).toHaveLength(1); + expect(evidenceGapActions(["toString", "constructor"])).toEqual([fallback]); + }); + + it("turns removal stop reasons into bounded stop-and-recheck actions", () => { + expect(removalStoppedAction("git-worktree-removal-live-reaudit-failed")).toBe( + "실행 직전 재감사에 실패했습니다. 저장소 상태를 확인한 뒤 새 감사부터 진행하세요.", + ); + expect(removalStoppedAction("git-worktree-removal-reference-drift")).toBe( + "보존 ref가 승인 이후 변경되었습니다. 최신 ref로 새 감사를 실행하고 다시 승인하세요.", + ); + expect(removalStoppedAction("git-worktree-removal-candidate-drift")).toBe( + "worktree 상태가 승인 이후 변경되었습니다. 새 감사를 실행하고 다시 승인하세요.", + ); + expect(removalStoppedAction("git-worktree-removal-command-failed")).toBe( + "Git worktree 제거 명령이 실패했습니다. worktree가 잠겨 있거나 사용 중인지 확인한 뒤 새 감사부터 진행하세요.", + ); + expect(removalStoppedAction("git-worktree-removal-post-verification-failed")).toBe( + "제거 후 경로·등록·브랜치 보존을 모두 확인하지 못했습니다. Git worktree 목록과 브랜치를 확인하고 추가 제거를 중단하세요.", + ); + + const injected = "/Users/example/private-worktree: remove failed"; + const fallback = + "제거 결과 검증이 불완전합니다. Git worktree 목록과 브랜치를 확인하고 추가 제거를 중단하세요."; + expect(removalStoppedAction(injected)).toBe(fallback); + expect(removalStoppedAction(null)).toBe(fallback); + expect(removalStoppedAction("")).toBe(fallback); + expect(removalStoppedAction("toString")).toBe(fallback); + expect(removalStoppedAction("constructor")).toBe(fallback); + expect(removalStoppedAction(injected)).not.toContain(injected); + }); + + it("keeps the existing planning and removal authority behind accessible feedback", () => { + const source = readSource("src/lib/GitWorktreeCleanup.svelte"); + + expect(source).toContain('role="alert"'); + expect(source).toContain("api.planStaleGitWorktrees(root, references)"); + expect(source).toContain("api.removeStaleGitWorktrees("); + expect(source).toContain("evidenceGapActions(entry.blockers)"); + expect(source).toContain("removalStoppedAction(removal.result.stopped_reason)"); + }); +}); diff --git a/src/lib/gitWorktreeFeedback.ts b/src/lib/gitWorktreeFeedback.ts new file mode 100644 index 000000000..e60919d5d --- /dev/null +++ b/src/lib/gitWorktreeFeedback.ts @@ -0,0 +1,92 @@ +/** Customer guidance shown when the native folder picker cannot return a Git repository. */ +export const GIT_WORKTREE_REPOSITORY_SELECTION_FAILURE = + "Git 저장소를 선택하지 못했습니다. 폴더 접근 권한과 저장소 위치를 확인한 뒤 다시 선택하세요."; + +/** Customer guidance shown when the native removal confirmation cannot be opened. */ +export const GIT_WORKTREE_CONFIRMATION_FAILURE = + "제거 확인 창을 열지 못했습니다. 다른 확인 창을 닫은 뒤 새 감사부터 다시 진행하세요."; + +/** Customer guidance shown when DiskSage cannot produce a fresh read-only worktree audit. */ +export const GIT_WORKTREE_AUDIT_FAILURE = + "Git worktree 감사를 완료하지 못했습니다. 저장소 경로와 보존할 ref가 현재 로컬에서 해석되는지 확인한 뒤 다시 감사하세요."; + +/** Customer guidance shown when an approved worktree removal cannot complete. */ +export const GIT_WORKTREE_REMOVAL_FAILURE = + "Git worktree 제거에 실패했습니다. 저장소 상태와 계획 지문을 다시 확인한 뒤 새 감사부터 진행하세요."; + +/** + * Customer guidance shown when the removal result exists but its immutable record did not persist. + * The native persistence error is deliberately not reflected because it can contain local paths. + */ +export const GIT_WORKTREE_RESULT_RECORD_FAILURE = + "제거 결과는 위와 같지만 기록을 저장하지 못했습니다. DiskSage 데이터 폴더의 권한과 여유 공간을 확인하고 이 화면의 결과를 별도로 보관하세요."; + +const UNKNOWN_EVIDENCE_GAP_ACTION = + "증거가 불완전합니다. 해당 worktree의 Git 상태와 활성 사용을 직접 확인한 뒤 다시 감사하세요."; + +const UNKNOWN_REMOVAL_STOP_ACTION = + "제거 결과 검증이 불완전합니다. Git worktree 목록과 브랜치를 확인하고 추가 제거를 중단하세요."; + +const EVIDENCE_GAP_ACTIONS: Readonly> = { + "worktree-path-evidence-incomplete": + "worktree 경로를 안전하게 확인하지 못했습니다. Git 등록 상태와 디렉터리 접근 권한을 확인한 뒤 다시 감사하세요.", + "git-status-evidence-incomplete": + "변경 사항 여부를 확인하지 못했습니다. 해당 worktree에서 Git 상태를 확인한 뒤 다시 감사하세요.", + "reference-containment-evidence-incomplete": + "HEAD가 보존 ref에 포함되는지 확인하지 못했습니다. ref를 fetch하고 정확한 ref 이름을 입력한 뒤 다시 감사하세요.", + "actor-cwd-evidence-incomplete": + "현재 디렉터리 사용 여부를 확인하지 못했습니다. DiskSage와 터미널의 현재 디렉터리를 worktree 밖으로 옮긴 뒤 다시 감사하세요.", + "size-evidence-incomplete": + "worktree 크기를 끝까지 측정하지 못했습니다. 접근 권한과 디스크 상태를 확인한 뒤 다시 감사하세요.", + "active-use-evidence-incomplete": + "열린 파일과 프로세스 사용 여부를 확인하지 못했습니다. 관련 앱과 터미널을 닫은 뒤 다시 감사하세요.", +}; + +const REMOVAL_STOP_ACTIONS: Readonly> = { + "git-worktree-removal-live-reaudit-failed": + "실행 직전 재감사에 실패했습니다. 저장소 상태를 확인한 뒤 새 감사부터 진행하세요.", + "git-worktree-removal-reference-drift": + "보존 ref가 승인 이후 변경되었습니다. 최신 ref로 새 감사를 실행하고 다시 승인하세요.", + "git-worktree-removal-candidate-drift": + "worktree 상태가 승인 이후 변경되었습니다. 새 감사를 실행하고 다시 승인하세요.", + "git-worktree-removal-command-failed": + "Git worktree 제거 명령이 실패했습니다. worktree가 잠겨 있거나 사용 중인지 확인한 뒤 새 감사부터 진행하세요.", + "git-worktree-removal-post-verification-failed": + "제거 후 경로·등록·브랜치 보존을 모두 확인하지 못했습니다. Git worktree 목록과 브랜치를 확인하고 추가 제거를 중단하세요.", +}; + +function ownAction( + actions: Readonly>, + code: string, + fallback: string, +): string { + return Object.prototype.hasOwnProperty.call(actions, code) ? actions[code] : fallback; +} + +/** + * Convert native evidence-gap codes into deduplicated, bounded customer actions. + * Unknown, inherited, or missing values never cross the UI boundary and still produce safe guidance. + */ +export function evidenceGapActions(codes: readonly string[]): string[] { + if (codes.length === 0) return [UNKNOWN_EVIDENCE_GAP_ACTION]; + + const actions: string[] = []; + const seen = new Set(); + for (const code of codes) { + const action = ownAction(EVIDENCE_GAP_ACTIONS, code, UNKNOWN_EVIDENCE_GAP_ACTION); + if (!seen.has(action)) { + seen.add(action); + actions.push(action); + } + } + return actions; +} + +/** + * Convert a native removal stop code into a bounded stop-and-recheck action. + * Unknown, inherited, or missing values never cross the UI boundary. + */ +export function removalStoppedAction(reason: string | null): string { + if (!reason) return UNKNOWN_REMOVAL_STOP_ACTION; + return ownAction(REMOVAL_STOP_ACTIONS, reason, UNKNOWN_REMOVAL_STOP_ACTION); +}