From f6e6d4c9488564388627987636fc4d336b90500a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 05:45:20 +0900 Subject: [PATCH 01/11] test: require bounded Git worktree errors --- .../gitWorktreeErrorPrivacyContract.test.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 src/lib/gitWorktreeErrorPrivacyContract.test.ts diff --git a/src/lib/gitWorktreeErrorPrivacyContract.test.ts b/src/lib/gitWorktreeErrorPrivacyContract.test.ts new file mode 100644 index 000000000..2e81754ef --- /dev/null +++ b/src/lib/gitWorktreeErrorPrivacyContract.test.ts @@ -0,0 +1,29 @@ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +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 exception text", () => { + const source = readSource("src/lib/GitWorktreeCleanup.svelte"); + + expect(source).not.toContain("String(e)"); + expect(source).toContain("Git 저장소 선택에 실패했습니다."); + expect(source).toContain("Git worktree 감사에 실패했습니다."); + expect(source).toContain("Git worktree 제거에 실패했습니다."); + }); + + it("keeps the existing planning and removal authority behind an accessible alert", () => { + const source = readSource("src/lib/GitWorktreeCleanup.svelte"); + + expect(source).toContain('role="alert"'); + expect(source).toContain("api.planStaleGitWorktrees(root, references)"); + expect(source).toContain("api.removeStaleGitWorktrees("); + }); +}); From 3f67363c78b2faa07f59895d2797ebeb48e62a86 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 05:47:43 +0900 Subject: [PATCH 02/11] fix: bound Git worktree failures --- src/lib/GitWorktreeCleanup.svelte | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/GitWorktreeCleanup.svelte b/src/lib/GitWorktreeCleanup.svelte index 0c45e040e..00584126e 100644 --- a/src/lib/GitWorktreeCleanup.svelte +++ b/src/lib/GitWorktreeCleanup.svelte @@ -57,7 +57,7 @@ repositoryRoot = selected; resetDecision(); } catch (e) { - error = String(e); + error = "Git 저장소 선택에 실패했습니다."; } } @@ -74,7 +74,7 @@ .map((binding) => binding.reference_ref) .join("\n"); } catch (e) { - error = String(e); + error = "Git worktree 감사에 실패했습니다."; } finally { planning = false; } @@ -112,7 +112,7 @@ confirmationPhrase = ""; rationale = ""; } catch (e) { - error = String(e); + error = "Git worktree 제거에 실패했습니다."; } finally { executing = false; } From 2b341f97faba129091edc1ca985b4fd92392304b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 21:28:27 +0900 Subject: [PATCH 03/11] test: require actionable bounded Git worktree recovery guidance --- .../gitWorktreeErrorPrivacyContract.test.ts | 120 +++++++++++++++++- 1 file changed, 115 insertions(+), 5 deletions(-) diff --git a/src/lib/gitWorktreeErrorPrivacyContract.test.ts b/src/lib/gitWorktreeErrorPrivacyContract.test.ts index 2e81754ef..bce31c4b7 100644 --- a/src/lib/gitWorktreeErrorPrivacyContract.test.ts +++ b/src/lib/gitWorktreeErrorPrivacyContract.test.ts @@ -2,6 +2,14 @@ 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_REMOVAL_FAILURE, + GIT_WORKTREE_REPOSITORY_SELECTION_FAILURE, + GIT_WORKTREE_RESULT_RECORD_FAILURE, + evidenceGapActions, + removalStoppedAction, +} from "./gitWorktreeFeedback"; const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); @@ -10,20 +18,122 @@ function readSource(path: string): string { } describe("Git worktree privacy-safe failure feedback", () => { - it("never renders arbitrary thrown exception text", () => { + 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).toContain("Git 저장소 선택에 실패했습니다."); - expect(source).toContain("Git worktree 감사에 실패했습니다."); - expect(source).toContain("Git worktree 제거에 실패했습니다."); + 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("keeps the existing planning and removal authority behind an accessible alert", () => { + it("uses path-free failure copy that directs the next safe action", () => { + expect(GIT_WORKTREE_REPOSITORY_SELECTION_FAILURE).toBe( + "Git 저장소를 선택하지 못했습니다. 폴더 접근 권한과 저장소 위치를 확인한 뒤 다시 선택하세요.", + ); + 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_AUDIT_FAILURE, + GIT_WORKTREE_REMOVAL_FAILURE, + GIT_WORKTREE_RESULT_RECORD_FAILURE, + ]) { + expect(message).not.toMatch(/(?:\/Users\/|[A-Za-z]:\\|file:\/\/)/); + expect(message).toMatch(/(?:확인|보관|선택|감사)/); + } + }); + + 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 injected = "/Users/example/private-worktree: probe failed"; + expect(evidenceGapActions([injected])).toEqual([ + "증거가 불완전합니다. 해당 worktree의 Git 상태와 활성 사용을 직접 확인한 뒤 다시 감사하세요.", + ]); + expect(evidenceGapActions([injected]).join(" ")).not.toContain(injected); + expect(evidenceGapActions([])).toEqual([ + "증거가 불완전합니다. 해당 worktree의 Git 상태와 활성 사용을 직접 확인한 뒤 다시 감사하세요.", + ]); + expect( + evidenceGapActions(["active-use-evidence-incomplete", "active-use-evidence-incomplete"]), + ).toHaveLength(1); + }); + + 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(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)"); }); }); From 380e667a5b7016ef61b9b79fd5d3575690f1c4ab Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 21:28:52 +0900 Subject: [PATCH 04/11] feat: map Git worktree failures to bounded next actions --- src/lib/gitWorktreeFeedback.ts | 79 ++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 src/lib/gitWorktreeFeedback.ts diff --git a/src/lib/gitWorktreeFeedback.ts b/src/lib/gitWorktreeFeedback.ts new file mode 100644 index 000000000..ec67dcac9 --- /dev/null +++ b/src/lib/gitWorktreeFeedback.ts @@ -0,0 +1,79 @@ +/** 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 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 목록과 브랜치를 확인하고 추가 제거를 중단하세요.", +}; + +/** + * Convert native evidence-gap codes into deduplicated, bounded customer actions. + * Unknown 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 = 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 or missing values never cross the UI boundary. + */ +export function removalStoppedAction(reason: string | null): string { + return (reason && REMOVAL_STOP_ACTIONS[reason]) ?? UNKNOWN_REMOVAL_STOP_ACTION; +} From f981d0e44aa1df2c9dab1ae888d37064827b135a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 21:29:47 +0900 Subject: [PATCH 05/11] fix: guide Git worktree failures without reflecting backend detail --- src/lib/GitWorktreeCleanup.svelte | 42 ++++++++++++++++++++----------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/src/lib/GitWorktreeCleanup.svelte b/src/lib/GitWorktreeCleanup.svelte index 00584126e..c78f6dd77 100644 --- a/src/lib/GitWorktreeCleanup.svelte +++ b/src/lib/GitWorktreeCleanup.svelte @@ -2,6 +2,14 @@ import { confirm, open } from "@tauri-apps/plugin-dialog"; import * as api from "./api"; import { fmtBytes } from "./fmt"; + import { + GIT_WORKTREE_AUDIT_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(); @@ -56,8 +64,8 @@ if (typeof selected !== "string") return; repositoryRoot = selected; resetDecision(); - } catch (e) { - error = "Git 저장소 선택에 실패했습니다."; + } catch { + error = GIT_WORKTREE_REPOSITORY_SELECTION_FAILURE; } } @@ -73,8 +81,8 @@ retentionText = report.retention_references .map((binding) => binding.reference_ref) .join("\n"); - } catch (e) { - error = "Git worktree 감사에 실패했습니다."; + } catch { + error = GIT_WORKTREE_AUDIT_FAILURE; } finally { planning = false; } @@ -111,8 +119,8 @@ ); confirmationPhrase = ""; rationale = ""; - } catch (e) { - error = "Git worktree 제거에 실패했습니다."; + } catch { + error = GIT_WORKTREE_REMOVAL_FAILURE; } finally { executing = false; } @@ -185,10 +193,17 @@ {#if evidenceGapEntries().length > 0}
- 증거가 부족해 전체 실행을 차단했습니다. + 증거가 부족해 전체 실행을 차단했습니다. 다음 항목을 확인하세요.
    {#each evidenceGapEntries() as entry (entry.path_fingerprint)} -
  • {entry.path} — {entry.blockers.join(", ")}
  • +
  • + {entry.path} +
      + {#each evidenceGapActions(entry.blockers) as action} +
    • {action}
    • + {/each} +
    +
  • {/each}
@@ -201,8 +216,8 @@ 사전 할당량 기준 최대 {fmtBytes(removal.result.removed_allocated_bytes_upper_bound)}입니다.

{:else} -

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

{/if} @@ -210,9 +225,7 @@ {#if removal.result_path}

결과 기록: {removal.result_path}

{:else} - + {/if} {:else if report.evidence_complete && report.exact_approval_phrase}
@@ -265,7 +278,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; } From f20218f0d2602baca3dd2d37369ddd11e6aa8345 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 21:30:22 +0900 Subject: [PATCH 06/11] test: reject empty Git worktree stop reasons --- src/lib/gitWorktreeErrorPrivacyContract.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/gitWorktreeErrorPrivacyContract.test.ts b/src/lib/gitWorktreeErrorPrivacyContract.test.ts index bce31c4b7..339cf4e68 100644 --- a/src/lib/gitWorktreeErrorPrivacyContract.test.ts +++ b/src/lib/gitWorktreeErrorPrivacyContract.test.ts @@ -124,6 +124,7 @@ describe("Git worktree privacy-safe failure feedback", () => { "제거 결과 검증이 불완전합니다. Git worktree 목록과 브랜치를 확인하고 추가 제거를 중단하세요."; expect(removalStoppedAction(injected)).toBe(fallback); expect(removalStoppedAction(null)).toBe(fallback); + expect(removalStoppedAction("")).toBe(fallback); expect(removalStoppedAction(injected)).not.toContain(injected); }); From 9d578770a9b538867d06e5bbd8c27cd1b184226a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 21:30:55 +0900 Subject: [PATCH 07/11] fix: fail closed for empty Git worktree stop reasons --- src/lib/gitWorktreeFeedback.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/gitWorktreeFeedback.ts b/src/lib/gitWorktreeFeedback.ts index ec67dcac9..367d472ec 100644 --- a/src/lib/gitWorktreeFeedback.ts +++ b/src/lib/gitWorktreeFeedback.ts @@ -75,5 +75,6 @@ export function evidenceGapActions(codes: readonly string[]): string[] { * Unknown or missing values never cross the UI boundary. */ export function removalStoppedAction(reason: string | null): string { - return (reason && REMOVAL_STOP_ACTIONS[reason]) ?? UNKNOWN_REMOVAL_STOP_ACTION; + if (!reason) return UNKNOWN_REMOVAL_STOP_ACTION; + return REMOVAL_STOP_ACTIONS[reason] ?? UNKNOWN_REMOVAL_STOP_ACTION; } From e4640c8f5c4b9fbbc4b128c490d71c342f1ea9c5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 30 Aug 2026 00:47:57 +0900 Subject: [PATCH 08/11] fix(ui): serialize Git worktree recovery actions --- src/lib/GitWorktreeCleanup.svelte | 90 ++++++++++++------- .../gitWorktreeErrorPrivacyContract.test.ts | 18 ++++ src/lib/gitWorktreeFeedback.ts | 4 + 3 files changed, 82 insertions(+), 30 deletions(-) diff --git a/src/lib/GitWorktreeCleanup.svelte b/src/lib/GitWorktreeCleanup.svelte index c78f6dd77..d3bcf781f 100644 --- a/src/lib/GitWorktreeCleanup.svelte +++ b/src/lib/GitWorktreeCleanup.svelte @@ -4,6 +4,7 @@ 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, @@ -16,12 +17,17 @@ 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; @@ -53,6 +59,8 @@ } async function chooseRepository() { + const seq = ++selectionSeq; + choosing = true; error = ""; try { const selected = await open({ @@ -61,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 { - error = GIT_WORKTREE_REPOSITORY_SELECTION_FAILURE; + if (seq === selectionSeq) error = GIT_WORKTREE_REPOSITORY_SELECTION_FAILURE; + } finally { + if (seq === selectionSeq) choosing = false; } } @@ -75,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 { - error = GIT_WORKTREE_AUDIT_FAILURE; + if (seq === auditSeq) error = GIT_WORKTREE_AUDIT_FAILURE; } finally { - planning = false; + if (seq === auditSeq) planning = false; } } @@ -95,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 = ""; + 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_REMOVAL_FAILURE; + error = GIT_WORKTREE_CONFIRMATION_FAILURE; } finally { - executing = false; + if (seq === removalSeq) confirming = false; } } @@ -143,10 +171,12 @@ oninput={resetDecision} autocomplete="off" spellcheck="false" - disabled={planning || executing} + disabled={choosing || planning || confirming || executing} /> - +
@@ -253,7 +283,7 @@ > {:else if report.removal_candidate_count === 0} diff --git a/src/lib/gitWorktreeErrorPrivacyContract.test.ts b/src/lib/gitWorktreeErrorPrivacyContract.test.ts index 339cf4e68..3f375d4eb 100644 --- a/src/lib/gitWorktreeErrorPrivacyContract.test.ts +++ b/src/lib/gitWorktreeErrorPrivacyContract.test.ts @@ -4,6 +4,7 @@ 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, @@ -34,6 +35,9 @@ describe("Git worktree privacy-safe failure feedback", () => { expect(GIT_WORKTREE_REPOSITORY_SELECTION_FAILURE).toBe( "Git 저장소를 선택하지 못했습니다. 폴더 접근 권한과 저장소 위치를 확인한 뒤 다시 선택하세요.", ); + expect(GIT_WORKTREE_CONFIRMATION_FAILURE).toBe( + "제거 확인 창을 열지 못했습니다. 다른 확인 창을 닫은 뒤 새 감사부터 다시 진행하세요.", + ); expect(GIT_WORKTREE_AUDIT_FAILURE).toBe( "Git worktree 감사를 완료하지 못했습니다. 저장소 경로와 보존할 ref가 현재 로컬에서 해석되는지 확인한 뒤 다시 감사하세요.", ); @@ -46,6 +50,7 @@ describe("Git worktree privacy-safe failure feedback", () => { 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, @@ -55,6 +60,19 @@ describe("Git worktree privacy-safe failure feedback", () => { } }); + 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]> = [ [ diff --git a/src/lib/gitWorktreeFeedback.ts b/src/lib/gitWorktreeFeedback.ts index 367d472ec..2d18d4353 100644 --- a/src/lib/gitWorktreeFeedback.ts +++ b/src/lib/gitWorktreeFeedback.ts @@ -2,6 +2,10 @@ 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가 현재 로컬에서 해석되는지 확인한 뒤 다시 감사하세요."; From 2a048d74ae38ea5fd4712f82679dfb1bb6057f01 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 05:14:01 +0900 Subject: [PATCH 09/11] test(worktree): reject record paths and inherited feedback keys --- .../gitWorktreeErrorPrivacyContract.test.ts | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/lib/gitWorktreeErrorPrivacyContract.test.ts b/src/lib/gitWorktreeErrorPrivacyContract.test.ts index 3f375d4eb..340f5e654 100644 --- a/src/lib/gitWorktreeErrorPrivacyContract.test.ts +++ b/src/lib/gitWorktreeErrorPrivacyContract.test.ts @@ -31,6 +31,17 @@ describe("Git worktree privacy-safe failure feedback", () => { 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 저장소를 선택하지 못했습니다. 폴더 접근 권한과 저장소 위치를 확인한 뒤 다시 선택하세요.", @@ -107,17 +118,16 @@ describe("Git worktree privacy-safe failure feedback", () => { }); 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([ - "증거가 불완전합니다. 해당 worktree의 Git 상태와 활성 사용을 직접 확인한 뒤 다시 감사하세요.", - ]); + expect(evidenceGapActions([injected])).toEqual([fallback]); expect(evidenceGapActions([injected]).join(" ")).not.toContain(injected); - expect(evidenceGapActions([])).toEqual([ - "증거가 불완전합니다. 해당 worktree의 Git 상태와 활성 사용을 직접 확인한 뒤 다시 감사하세요.", - ]); + 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", () => { @@ -143,6 +153,8 @@ describe("Git worktree privacy-safe failure feedback", () => { 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); }); From 06ee6ae6cd286c6c299e3fc9c826dc27fa90e4f4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 05:14:33 +0900 Subject: [PATCH 10/11] fix(worktree): fail closed on inherited feedback keys --- src/lib/gitWorktreeFeedback.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/lib/gitWorktreeFeedback.ts b/src/lib/gitWorktreeFeedback.ts index 2d18d4353..e60919d5d 100644 --- a/src/lib/gitWorktreeFeedback.ts +++ b/src/lib/gitWorktreeFeedback.ts @@ -55,9 +55,17 @@ const REMOVAL_STOP_ACTIONS: Readonly> = { "제거 후 경로·등록·브랜치 보존을 모두 확인하지 못했습니다. 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 or missing values never cross the UI boundary and still produce safe guidance. + * 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]; @@ -65,7 +73,7 @@ export function evidenceGapActions(codes: readonly string[]): string[] { const actions: string[] = []; const seen = new Set(); for (const code of codes) { - const action = EVIDENCE_GAP_ACTIONS[code] ?? UNKNOWN_EVIDENCE_GAP_ACTION; + const action = ownAction(EVIDENCE_GAP_ACTIONS, code, UNKNOWN_EVIDENCE_GAP_ACTION); if (!seen.has(action)) { seen.add(action); actions.push(action); @@ -76,9 +84,9 @@ export function evidenceGapActions(codes: readonly string[]): string[] { /** * Convert a native removal stop code into a bounded stop-and-recheck action. - * Unknown or missing values never cross the UI boundary. + * 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 REMOVAL_STOP_ACTIONS[reason] ?? UNKNOWN_REMOVAL_STOP_ACTION; + return ownAction(REMOVAL_STOP_ACTIONS, reason, UNKNOWN_REMOVAL_STOP_ACTION); } From 5ea48d6a45a153823423d27066662f77a4f97788 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 05:15:19 +0900 Subject: [PATCH 11/11] fix(worktree): hide immutable record paths in UI --- src/lib/GitWorktreeCleanup.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/GitWorktreeCleanup.svelte b/src/lib/GitWorktreeCleanup.svelte index d3bcf781f..75b689edb 100644 --- a/src/lib/GitWorktreeCleanup.svelte +++ b/src/lib/GitWorktreeCleanup.svelte @@ -251,9 +251,9 @@ 확인된 제거 {removal.result.removed_count}/{removal.result.planned_candidate_count}개입니다.

{/if} -

승인 기록: {removal.approval_path}

+

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

{#if removal.result_path} -

결과 기록: {removal.result_path}

+

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

{:else} {/if}