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}
-
증거가 부족해 전체 실행을 차단했습니다.
+
증거가 부족해 전체 실행을 차단했습니다. 다음 항목을 확인하세요.
{#each evidenceGapEntries() as entry (entry.path_fingerprint)}
- - {entry.path} — {entry.blockers.join(", ")}
+ -
+ {entry.path}
+
+ {#each evidenceGapActions(entry.blockers) as action}
+ - {action}
+ {/each}
+
+
{/each}
@@ -201,18 +246,16 @@
사전 할당량 기준 최대 {fmtBytes(removal.result.removed_allocated_bytes_upper_bound)}입니다.
{:else}
-
- 일부 또는 사후 검증이 완료되지 않았습니다: {removal.result.stopped_reason ?? "검증 불완전"}.
+
+ {removalStoppedAction(removal.result.stopped_reason)}
확인된 제거 {removal.result.removed_count}/{removal.result.planned_candidate_count}개입니다.
{/if}
- 승인 기록: {removal.approval_path}
+ 승인 기록을 DiskSage 데이터 폴더에 저장했습니다.
{#if removal.result_path}
- 결과 기록: {removal.result_path}
+ 결과 기록을 DiskSage 데이터 폴더에 저장했습니다.
{:else}
-
- 실행 결과는 위와 같지만 결과 기록을 저장하지 못했습니다: {removal.result_record_error}
-
+ {GIT_WORKTREE_RESULT_RECORD_FAILURE}
{/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);
+}