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 ?? "검증 불완전"}.
+
+ {removalStoppedAction(removal.result.stopped_reason)}
확인된 제거 {removal.result.removed_count}/{removal.result.planned_candidate_count}개입니다.
{/if}
@@ -210,9 +225,7 @@
{#if removal.result_path}
결과 기록: {removal.result_path}
{:else}
-
- 실행 결과는 위와 같지만 결과 기록을 저장하지 못했습니다: {removal.result_record_error}
-
+ {GIT_WORKTREE_RESULT_RECORD_FAILURE}
{/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}
{GIT_WORKTREE_RESULT_RECORD_FAILURE}
{/if}