From 217ca10c29f187cd950a43a9e4842d1c541083ef Mon Sep 17 00:00:00 2001 From: Ash Shah <494shah@gmail.com> Date: Thu, 16 Jul 2026 09:32:06 -0700 Subject: [PATCH 01/20] fix(prereq): gate history rewrite on the bundled git-filter-repo probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex flagged (PR #14) that PrereqBanner's "Commit-time editing is disabled" was cosmetic: the check lived in the banner's local state, so ApplyPanel still enabled "Rewrite history" whenever dates were queued — letting the user confirm the destructive op (and create a backup) before it inevitably failed for lack of a working git-filter-repo. Lift the probe into a shared singleton (prerequisites.svelte.ts) so the banner and the editing controls read the SAME result. ApplyPanel now disables the Rewrite button (with an explanatory tooltip) when canEditHistory is false, and rewrite() bails early as defense in depth. Unknown/non-Tauri → treated as available so nothing is blocked prematurely. Co-Authored-By: Claude Opus 4.8 --- src/lib/components/ApplyPanel.svelte | 18 +++++++++++- src/lib/components/PrereqBanner.svelte | 12 +++----- src/lib/prerequisites.svelte.ts | 38 ++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 9 deletions(-) create mode 100644 src/lib/prerequisites.svelte.ts diff --git a/src/lib/components/ApplyPanel.svelte b/src/lib/components/ApplyPanel.svelte index a39786a..7d2f128 100644 --- a/src/lib/components/ApplyPanel.svelte +++ b/src/lib/components/ApplyPanel.svelte @@ -1,6 +1,8 @@ {#if dialogs.state.kind === "prompt"} @@ -381,6 +392,23 @@ +{:else if dialogs.state.kind === "alert"} + +
{ + if (e.target === e.currentTarget) dialogs.resolveAlert(); + }} + onkeydown={handleAlertKey} + > +
+

{dialogs.state.title}

+

{dialogs.state.message}

+
+ +
+
+
{/if} diff --git a/src/lib/components/Sidebar.svelte b/src/lib/components/Sidebar.svelte index 2ec891d..469a793 100644 --- a/src/lib/components/Sidebar.svelte +++ b/src/lib/components/Sidebar.svelte @@ -9,8 +9,10 @@ import CollapsiblePanel from "./CollapsiblePanel.svelte"; import RefTree from "./RefTree.svelte"; import StashPanel from "./StashPanel.svelte"; + import WorktreePanel from "./WorktreePanel.svelte"; import { githubState } from "../githubState.svelte"; import { prForBranch } from "../github/branchPr"; + import { worktreeForBranch } from "../worktrees"; const refs = $derived(appState.refsByKind); // Folderize slashed ref names (feature/x → feature ▸ x), Fork/SourceTree-style. @@ -18,6 +20,10 @@ const remoteTree = $derived(buildRefTree(refs.remote)); const tagTree = $derived(buildRefTree(refs.tags)); + function worktreeFor(branch: string) { + return worktreeForBranch(appState.worktrees, branch); + } + // Uncommitted-change count for the pinned "Working Copy" entry — mirrors the // count used by the synthetic graph row. const wcCount = $derived( @@ -76,7 +82,8 @@ tracking !== null && appState.refsByKind.remote.some((r) => r.name === tracking); const upstream = remoteExists ? tracking : null; const remoteGone = tracking !== null && !remoteExists; - const res = await dialogs.confirmBranchDelete({ branch: name, upstream, remoteGone }); + const worktree = worktreeFor(name) ?? null; + const res = await dialogs.confirmBranchDelete({ branch: name, upstream, remoteGone, worktree }); if (!res.confirmed) return; let remote: string | undefined; let remoteBranch: string | undefined; @@ -85,7 +92,14 @@ remote = upstream.slice(0, slash); remoteBranch = upstream.slice(slash + 1); } - await gitActions.deleteBranch(name, res.force, res.deleteRemote && !!upstream, remote, remoteBranch); + await gitActions.deleteBranch( + name, + res.force, + res.deleteRemote && !!upstream, + remote, + remoteBranch, + res.removeWorktree && worktree ? worktree.path : undefined, + ); } function onRefContext(event: MouseEvent, r: RefEntry, kind: "local" | "remote" | "tag") { @@ -287,10 +301,16 @@ HEAD detached @ {refs.head[0].sha.slice(0, 7)} {/if} - appState.colorForRef(ref.name, ref.sha)} onCheckout={onRefCheckout} selectedKey={selectedRefKey} onSelect={selectRef} /> + appState.colorForRef(ref.name, ref.sha)} onCheckout={onRefCheckout} selectedKey={selectedRefKey} onSelect={selectRef} {worktreeFor} /> {#if refs.local.length === 0}

No local branches

{/if} + + {#snippet headerActions()}{appState.worktrees.length}{/snippet} + + {#if appState.worktrees.length === 0}

No worktrees

{/if} +
+ {#snippet headerActions()}{refs.remote.length}{/snippet} appState.colorForRef(ref.name, ref.sha)} onCheckout={onRefCheckout} selectedKey={selectedRefKey} onSelect={selectRef} /> diff --git a/src/lib/components/WorktreeIcon.svelte b/src/lib/components/WorktreeIcon.svelte new file mode 100644 index 0000000..f5e1212 --- /dev/null +++ b/src/lib/components/WorktreeIcon.svelte @@ -0,0 +1,29 @@ + + + + + + diff --git a/src/lib/components/WorktreePanel.svelte b/src/lib/components/WorktreePanel.svelte new file mode 100644 index 0000000..040245e --- /dev/null +++ b/src/lib/components/WorktreePanel.svelte @@ -0,0 +1,107 @@ + + +
+ {#each ordered as worktree (worktree.path)} +
+ + + {label(worktree)} + {worktree.path} + + {#if worktreeStatusLabel(worktree)} + {worktreeStatusLabel(worktree)} + {/if} +
+ {/each} +
+ + diff --git a/src/lib/dialogs.svelte.ts b/src/lib/dialogs.svelte.ts index d03cda0..c684271 100644 --- a/src/lib/dialogs.svelte.ts +++ b/src/lib/dialogs.svelte.ts @@ -1,6 +1,16 @@ // Promise-based modal dialogs so callers can `await dialogs.prompt(...)` / // `await dialogs.confirm(...)` inline. A single mounted once renders // whichever dialog is active. +import type { WorktreeInfo } from "./types"; +import { worktreeRemovalBlocker } from "./worktrees"; + +type BranchDeleteResult = { + confirmed: boolean; + force: boolean; + deleteRemote: boolean; + removeWorktree: boolean; +}; + type DialogState = | { kind: "none" } | { @@ -52,7 +62,9 @@ type DialogState = remoteGone: boolean; // tracking config exists but the remote branch was already deleted force: boolean; deleteRemote: boolean; - resolve: (v: { confirmed: boolean; force: boolean; deleteRemote: boolean }) => void; + worktree: WorktreeInfo | null; + removeWorktree: boolean; + resolve: (v: BranchDeleteResult) => void; } | { kind: "createRepo"; @@ -83,7 +95,7 @@ function makeDialogs() { else if (state.kind === "confirm") state.resolve(false); else if (state.kind === "destructive") state.resolve({ confirmed: false, backup: false }); else if (state.kind === "credentials") state.resolve(null); - else if (state.kind === "branchDelete") state.resolve({ confirmed: false, force: false, deleteRemote: false }); + else if (state.kind === "branchDelete") state.resolve({ confirmed: false, force: false, deleteRemote: false, removeWorktree: false }); else if (state.kind === "createRepo") state.resolve({ confirmed: false, name: "", isPrivate: true, description: "" }); else if (state.kind === "createPr") state.resolve(null); else if (state.kind === "alert") state.resolve(); @@ -218,18 +230,22 @@ function makeDialogs() { } }, // ── Branch delete dialog ────────────────────────────────────────────────── - confirmBranchDelete(opts: { branch: string; upstream: string | null; remoteGone?: boolean }): Promise<{ confirmed: boolean; force: boolean; deleteRemote: boolean }> { + confirmBranchDelete(opts: { branch: string; upstream: string | null; remoteGone?: boolean; worktree?: WorktreeInfo | null }): Promise { settlePending(); return new Promise((resolve) => { - state = { kind: "branchDelete", title: "Delete branch", branch: opts.branch, upstream: opts.upstream, remoteGone: opts.remoteGone ?? false, force: false, deleteRemote: false, resolve }; + state = { kind: "branchDelete", title: "Delete branch", branch: opts.branch, upstream: opts.upstream, remoteGone: opts.remoteGone ?? false, force: false, deleteRemote: false, worktree: opts.worktree ?? null, removeWorktree: false, resolve }; }); }, setBranchDeleteForce(v: boolean) { if (state.kind === "branchDelete") state = { ...state, force: v }; }, setBranchDeleteRemote(v: boolean) { if (state.kind === "branchDelete") state = { ...state, deleteRemote: v }; }, + setBranchDeleteWorktree(v: boolean) { if (state.kind === "branchDelete") state = { ...state, removeWorktree: v }; }, resolveBranchDelete(confirmed: boolean) { if (state.kind === "branchDelete") { - const { force, deleteRemote } = state; - state.resolve({ confirmed, force, deleteRemote }); + const { force, deleteRemote, worktree, removeWorktree } = state; + // Defense in depth: keyboard handlers or a future caller cannot bypass + // the explicit opt-in or the clean/linked-worktree safety gate. + if (confirmed && worktree && (!removeWorktree || worktreeRemovalBlocker(worktree))) return; + state.resolve({ confirmed, force, deleteRemote, removeWorktree }); state = { kind: "none" }; } }, diff --git a/src/lib/dialogs.worktree.test.ts b/src/lib/dialogs.worktree.test.ts new file mode 100644 index 0000000..75bcd61 --- /dev/null +++ b/src/lib/dialogs.worktree.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { dialogs } from "./dialogs.svelte"; +import type { WorktreeInfo } from "./types"; + +function cleanLinkedWorktree(): WorktreeInfo { + return { + path: "/tmp/git-it-linked-worktree", + head: "a".repeat(40), + branch: "feature", + isMain: false, + isCurrent: false, + detached: false, + bare: false, + locked: false, + lockedReason: null, + prunable: false, + prunableReason: null, + status: { + head: { sha: "a".repeat(40), branch: "feature", detached: false }, + staged: 0, + unstaged: 0, + untracked: 0, + conflicted: 0, + operation: null, + }, + }; +} + +describe("linked-worktree branch deletion", () => { + it("cannot confirm until removal is explicitly selected", async () => { + const result = dialogs.confirmBranchDelete({ + branch: "feature", + upstream: null, + worktree: cleanLinkedWorktree(), + }); + + expect(dialogs.state.kind).toBe("branchDelete"); + if (dialogs.state.kind !== "branchDelete") throw new Error("branch dialog did not open"); + expect(dialogs.state.removeWorktree).toBe(false); + + dialogs.resolveBranchDelete(true); + expect(dialogs.state.kind).toBe("branchDelete"); + + dialogs.setBranchDeleteWorktree(true); + dialogs.resolveBranchDelete(true); + + await expect(result).resolves.toEqual({ + confirmed: true, + force: false, + deleteRemote: false, + removeWorktree: true, + }); + expect(dialogs.state.kind).toBe("none"); + }); +}); diff --git a/src/lib/gitActions.ts b/src/lib/gitActions.ts index ed08b04..4236204 100644 --- a/src/lib/gitActions.ts +++ b/src/lib/gitActions.ts @@ -38,16 +38,23 @@ export async function refreshWorkingChanges(): Promise { } } -// Refresh the detailed ref list (ahead/behind/upstream) and the remotes list. +// Refresh the detailed ref list (ahead/behind/upstream), remotes, and worktrees. // Tauri-only; silently no-ops in browser preview. export async function refreshRefs(): Promise { if (!isTauri() || !appState.repo) return; - try { - appState.setRefsDetailed(await api.listRefs(appState.repo)); - appState.setRemotes(await api.remotes(appState.repo)); - } catch (e) { - console.warn("[gte] refs/remotes refresh failed", e); - } + const target = appState.repo; + const [refs, remotes, worktrees] = await Promise.allSettled([ + api.listRefs(target), + api.remotes(target), + api.listWorktrees(target), + ]); + if (appState.repo !== target) return; + if (refs.status === "fulfilled") appState.setRefsDetailed(refs.value); + else console.warn("[gte] refs refresh failed", refs.reason); + if (remotes.status === "fulfilled") appState.setRemotes(remotes.value); + else console.warn("[gte] remotes refresh failed", remotes.reason); + if (worktrees.status === "fulfilled") appState.setWorktrees(worktrees.value); + else console.warn("[gte] worktrees refresh failed", worktrees.reason); } // ── Live working-copy watching (filesystem watcher + focus refresh) ─────────── @@ -200,12 +207,17 @@ export async function reloadGraph(): Promise { appState.setGraphHasMore(false); appState.setRepoStatus(null); appState.setRefsDetailed([]); + appState.setWorktrees([]); appState.setWorkingChanges([]); appState.status = `Could not open ${target}: ${e}`; } return; } if (appState.repo !== target) return; + // Once the new repository's graph replaces the old one, old worktree paths + // must no longer remain visible or actionable while their refresh settles. + // Same-repository reloads keep the cached list on transient failures. + if (appState.repoLoading) appState.setWorktrees([]); appState.setGraphCommits(gc); appState.setGraphHasMore(gc.length === PAGE); await refreshStatus(); @@ -575,9 +587,10 @@ export const gitActions = { deleteRemote = false, remote?: string, remoteBranch?: string, + worktreePath?: string, ) => run(`Delete branch ${name}`, () => - api.deleteBranch(appState.repo, name, force, deleteRemote, remote, remoteBranch), + api.deleteBranch(appState.repo, name, force, deleteRemote, remote, remoteBranch, worktreePath), ), deleteRemoteBranch: (remote: string, branch: string) => run(`Delete remote branch ${remote}/${branch}`, () => diff --git a/src/lib/store.svelte.ts b/src/lib/store.svelte.ts index bf8b542..515b9fb 100644 --- a/src/lib/store.svelte.ts +++ b/src/lib/store.svelte.ts @@ -1,6 +1,6 @@ // Centralized reactive app state using Svelte 5 runes. // Components import this module and read/write fields directly. -import type { Commit, GraphCommit, Ref, RefEntry, RemoteInfo, RepoStatus, UndoSnapshot, WorkingFile } from "./types"; +import type { Commit, GraphCommit, Ref, RefEntry, RemoteInfo, RepoStatus, UndoSnapshot, WorkingFile, WorktreeInfo } from "./types"; import type { DateFormatPrefs } from "./dates"; import type { Store } from "@tauri-apps/plugin-store"; import { @@ -1585,9 +1585,11 @@ function makeState() { // ── Remote state (Phase 6) ──────────────────────────────────────────────── // refsDetailed: the result of api.listRefs (includes upstream/ahead/behind). + // worktrees: registered main + linked worktrees, including their checked-out branches. // remotes: the result of api.remotes (name + url). // remoteOpActive / remoteLog: live progress for an in-flight pull/push. let refsDetailed = $state([]); + let worktrees = $state([]); let remotesState = $state([]); let remoteOpActive = $state(false); // True from the moment a repo switch begins until that repo's reloadGraph finishes. @@ -1644,6 +1646,7 @@ function makeState() { workingChanges = []; workingChangesRev = 0; refsDetailed = []; + worktrees = []; remotesState = []; graphCommits = []; commits = []; @@ -2215,6 +2218,12 @@ function makeState() { setRefsDetailed(v: Ref[]) { refsDetailed = v; }, + get worktrees() { + return worktrees; + }, + setWorktrees(v: WorktreeInfo[]) { + worktrees = v; + }, get remotes() { return remotesState; }, diff --git a/src/lib/types.ts b/src/lib/types.ts index f97a496..0713604 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -98,6 +98,21 @@ export type RepoStatus = { operation: string | null; }; +export type WorktreeInfo = { + path: string; + head: string | null; + branch: string | null; + isMain: boolean; + isCurrent: boolean; + detached: boolean; + bare: boolean; + locked: boolean; + lockedReason: string | null; + prunable: boolean; + prunableReason: string | null; + status: RepoStatus | null; +}; + export type OpOutcome = { conflicted: boolean; files: string[]; diff --git a/src/lib/worktrees.test.ts b/src/lib/worktrees.test.ts new file mode 100644 index 0000000..8696abc --- /dev/null +++ b/src/lib/worktrees.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import type { WorktreeInfo } from "./types"; +import { + worktreeForBranch, + worktreeIsDirty, + worktreeRemovalBlocker, + worktreeStatusLabel, +} from "./worktrees"; + +function worktree(overrides: Partial = {}): WorktreeInfo { + return { + path: "/tmp/repo-worktree", + head: "a".repeat(40), + branch: "feature", + isMain: false, + isCurrent: false, + detached: false, + bare: false, + locked: false, + lockedReason: null, + prunable: false, + prunableReason: null, + status: { + head: { sha: "a".repeat(40), branch: "feature", detached: false }, + staged: 0, + unstaged: 0, + untracked: 0, + conflicted: 0, + operation: null, + }, + ...overrides, + }; +} + +describe("worktree helpers", () => { + it("maps a local branch to its worktree", () => { + const feature = worktree(); + expect(worktreeForBranch([feature], "feature")).toBe(feature); + expect(worktreeForBranch([feature], "main")).toBeUndefined(); + }); + + it("treats file changes, conflicts, and in-progress operations as dirty", () => { + expect(worktreeIsDirty(worktree())).toBe(false); + expect(worktreeIsDirty(worktree({ status: { ...worktree().status!, untracked: 1 } }))).toBe(true); + expect(worktreeIsDirty(worktree({ status: { ...worktree().status!, operation: "rebase" } }))).toBe(true); + }); + + it("allows only a verified clean linked worktree to be removed", () => { + expect(worktreeRemovalBlocker(worktree())).toBeNull(); + expect(worktreeRemovalBlocker(worktree({ isCurrent: true }))).toContain("currently open"); + expect(worktreeRemovalBlocker(worktree({ isMain: true }))).toContain("main worktree"); + expect(worktreeRemovalBlocker(worktree({ locked: true, lockedReason: "in use" }))).toContain("in use"); + expect(worktreeRemovalBlocker(worktree({ prunable: true }))).toContain("stale or missing"); + expect(worktreeRemovalBlocker(worktree({ status: null }))).toContain("could not verify"); + expect( + worktreeRemovalBlocker(worktree({ status: { ...worktree().status!, unstaged: 1 } })), + ).toContain("uncommitted changes"); + }); + + it("prioritizes the most useful status label", () => { + expect(worktreeStatusLabel(worktree({ isCurrent: true, locked: true }))).toBe("Current"); + expect(worktreeStatusLabel(worktree({ locked: true }))).toBe("Locked"); + expect(worktreeStatusLabel(worktree({ prunable: true }))).toBe("Missing"); + expect(worktreeStatusLabel(worktree({ status: { ...worktree().status!, staged: 1 } }))).toBe("Modified"); + expect(worktreeStatusLabel(worktree())).toBeNull(); + }); +}); diff --git a/src/lib/worktrees.ts b/src/lib/worktrees.ts new file mode 100644 index 0000000..fa18fc6 --- /dev/null +++ b/src/lib/worktrees.ts @@ -0,0 +1,61 @@ +import type { WorktreeInfo } from "./types"; + +export function worktreeForBranch( + worktrees: readonly WorktreeInfo[], + branch: string, +): WorktreeInfo | undefined { + return worktrees.find((worktree) => worktree.branch === branch); +} + +export function worktreeIsDirty(worktree: WorktreeInfo): boolean { + const status = worktree.status; + return !!status && ( + status.staged > 0 || + status.unstaged > 0 || + status.untracked > 0 || + status.conflicted > 0 || + status.operation !== null + ); +} + +/** A non-null result means the app must not offer removal. The backend repeats + * every check immediately before executing; this is the explanatory UI gate. */ +export function worktreeRemovalBlocker(worktree: WorktreeInfo): string | null { + if (worktree.isMain) { + return "Git's main worktree cannot be removed. Check out a different branch there before deleting this branch."; + } + if (worktree.isCurrent) { + return "This is the worktree currently open in Git It. Open the repository from another worktree before removing it."; + } + if (worktree.locked) { + return worktree.lockedReason + ? `This worktree is locked: ${worktree.lockedReason}` + : "This worktree is locked. Unlock it before removing it."; + } + if (worktree.prunable) { + return worktree.prunableReason + ? `This worktree is stale or missing: ${worktree.prunableReason}` + : "This worktree is stale or missing. Prune its Git metadata before deleting the branch."; + } + if (worktree.bare || worktree.detached || worktree.branch === null) { + return "This worktree no longer has the branch checked out. Refresh the repository and try again."; + } + if (worktree.status === null) { + return "Git It could not verify that this worktree is clean, so it will not remove it."; + } + if (worktreeIsDirty(worktree)) { + return "This worktree has uncommitted changes or an operation in progress. Commit, stash, or discard them before removing it."; + } + return null; +} + +export function worktreeStatusLabel(worktree: WorktreeInfo): string | null { + if (worktree.isCurrent) return "Current"; + if (worktree.locked) return "Locked"; + if (worktree.prunable) return "Missing"; + if (worktree.status === null && !worktree.bare) return "Unavailable"; + if (worktreeIsDirty(worktree)) return "Modified"; + if (worktree.detached) return "Detached"; + if (worktree.bare) return "Bare"; + return null; +} From 601d070e261e38dff6e198b6e1795542322ee630 Mon Sep 17 00:00:00 2001 From: Ash Shah <494shah@gmail.com> Date: Fri, 17 Jul 2026 16:15:57 -0700 Subject: [PATCH 17/20] fix: address worktree review findings --- crates/git-core/src/graph.rs | 12 + crates/git-core/src/ops.rs | 412 ++++++++++++++++++---- src/lib/gitActions.partialFailure.test.ts | 358 +++++++++++++++++++ src/lib/gitActions.ts | 274 ++++++++++---- src/lib/store.svelte.ts | 51 ++- src/lib/store.viewmodel.test.ts | 57 +++ src/lib/worktrees.test.ts | 4 + 7 files changed, 1008 insertions(+), 160 deletions(-) create mode 100644 src/lib/gitActions.partialFailure.test.ts diff --git a/crates/git-core/src/graph.rs b/crates/git-core/src/graph.rs index d572767..ff526a9 100644 --- a/crates/git-core/src/graph.rs +++ b/crates/git-core/src/graph.rs @@ -262,6 +262,9 @@ fn detect_operation(repo: &Path) -> Option { if exists("REVERT_HEAD") { return Some("revert".to_string()); } + if exists("BISECT_START") { + return Some("bisect".to_string()); + } None } @@ -527,6 +530,15 @@ mod tests { assert_eq!(repo_status(&r.path).unwrap().operation.as_deref(), Some("merge")); } + #[test] + fn repo_status_detects_in_progress_bisect() { + let r = TempRepo::new(); + r.write_commit("base.txt", "base"); + r.git(&["bisect", "start"]); + + assert_eq!(repo_status(&r.path).unwrap().operation.as_deref(), Some("bisect")); + } + #[test] fn detect_operation_resolves_linked_worktree_gitdir() { let r = TempRepo::new(); diff --git a/crates/git-core/src/ops.rs b/crates/git-core/src/ops.rs index 15f4935..36b1783 100644 --- a/crates/git-core/src/ops.rs +++ b/crates/git-core/src/ops.rs @@ -102,10 +102,9 @@ struct WorktreeHeadLock { struct BranchDeleteSafety { branch_oid: String, - // The upstream or current branch used as the `-d` merge baseline. Adding a - // verify-only entry for it to the prepared transaction holds that ref at - // the exact OID on which the safety decision was based. - baseline: Option<(String, String)>, + // Verify-only entries held by the prepared transaction. Some assert a ref's + // exact OID; `None` asserts that a configured-but-gone upstream stays absent. + verifications: Vec<(String, Option)>, } /// A prepared `git update-ref` transaction holds the branch ref lock while the @@ -124,7 +123,7 @@ impl PreparedBranchDelete { repo: &Path, refname: &str, expected_oid: &str, - baseline: Option<(&str, &str)>, + verifications: &[(String, Option)], ) -> Result { let mut child = Command::new("git") .current_dir(repo) @@ -152,8 +151,15 @@ impl PreparedBranchDelete { transaction.send("start")?; transaction.expect_response("start: ok")?; transaction.send(&format!("delete {refname} {expected_oid}"))?; - if let Some((baseline_ref, baseline_oid)) = baseline { - transaction.send(&format!("verify {baseline_ref} {baseline_oid}"))?; + let zero_oid = "0".repeat(expected_oid.len()); + for (verification_ref, expected_oid) in verifications { + match expected_oid { + Some(oid) => transaction.send(&format!("verify {verification_ref} {oid}"))?, + // An all-zero old OID asserts that the ref does not exist. Match + // the repository's object format (SHA-1 or SHA-256) by deriving + // the width from the branch OID already being deleted. + None => transaction.send(&format!("verify {verification_ref} {zero_oid}"))?, + } } transaction.send("prepare")?; transaction.expect_response("prepare: ok")?; @@ -299,17 +305,24 @@ fn acquire_worktree_head_lock(repo: &Path, worktree: &Path) -> Result Result { +fn exact_ref_oid_if_exists(repo: &Path, refname: &str) -> Result, String> { let output = Command::new("git") .current_dir(repo) - .args(["show-ref", "--verify", "--hash", refname]) + // Full refnames avoid DWIM ambiguity; --quiet gives a distinct exit 1 + // with no fatal diagnostic when the exact ref is simply absent. + .args(["rev-parse", "--verify", "--quiet", refname]) .output() .map_err(|e| format!("Failed to inspect branch: {e}"))?; if !output.status.success() { - return Err(format!( - "Branch '{}' no longer exists. Refresh and try again.", - refname.strip_prefix("refs/heads/").unwrap_or(refname) - )); + if output.status.code() == Some(1) { + return Ok(None); + } + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(if stderr.trim().is_empty() { + "Could not inspect the branch reference.".to_string() + } else { + stderr.into_owned() + }); } let oid = str::from_utf8(&output.stdout) .map_err(|_| "Git returned an invalid branch object ID.".to_string())? @@ -317,7 +330,16 @@ fn exact_ref_oid(repo: &Path, refname: &str) -> Result { if oid.is_empty() { return Err("Git returned an empty branch object ID.".to_string()); } - Ok(oid.to_string()) + Ok(Some(oid.to_string())) +} + +fn exact_ref_oid(repo: &Path, refname: &str) -> Result { + exact_ref_oid_if_exists(repo, refname)?.ok_or_else(|| { + format!( + "Branch '{}' no longer exists. Refresh and try again.", + refname.strip_prefix("refs/heads/").unwrap_or(refname) + ) + }) } fn branch_upstream(repo: &Path, refname: &str) -> Result, String> { @@ -350,60 +372,73 @@ fn branch_upstream(repo: &Path, refname: &str) -> Result, String> ) } +fn head_delete_baseline(repo: &Path) -> Result<(String, (String, Option)), String> { + let symbolic = Command::new("git") + .current_dir(repo) + .args(["symbolic-ref", "--quiet", "HEAD"]) + .output() + .map_err(|e| format!("Failed to inspect HEAD: {e}"))?; + if symbolic.status.success() { + let baseline_ref = str::from_utf8(&symbolic.stdout) + .map_err(|_| "Git returned an invalid HEAD reference.".to_string())? + .trim(); + if baseline_ref.is_empty() { + return Err("Git returned an empty HEAD reference.".to_string()); + } + let oid = exact_ref_oid(repo, baseline_ref)?; + return Ok((oid.clone(), (baseline_ref.to_string(), Some(oid)))); + } + + // Detached HEAD is a direct ref, so verify-and-lock HEAD itself. + let output = Command::new("git") + .current_dir(repo) + .args(["rev-parse", "--verify", "HEAD^{commit}"]) + .output() + .map_err(|e| format!("Failed to inspect HEAD: {e}"))?; + if !output.status.success() { + return Err( + "HEAD is unavailable, so safe branch deletion could not be verified." + .to_string(), + ); + } + let oid = str::from_utf8(&output.stdout) + .map_err(|_| "Git returned an invalid HEAD object ID.".to_string())? + .trim(); + if oid.is_empty() { + return Err("Git returned an empty HEAD object ID.".to_string()); + } + let oid = oid.to_string(); + Ok((oid.clone(), ("HEAD".to_string(), Some(oid)))) +} + /// Mirror `git branch -d`'s safety rule before deleting the worktree folder: -/// the branch must be merged into its configured upstream, or into HEAD when -/// it has no upstream. The returned baseline is verified and held by the same -/// prepared ref transaction that deletes the branch. +/// the branch must be merged into its configured upstream, or into HEAD when it +/// has no resolvable upstream. The latter includes a configured remote-tracking +/// ref that has already been deleted, matching Git's own `branch -d` behavior. +/// The chosen baseline—and, when relevant, the continued absence of a gone +/// upstream—is verified and held by the same prepared ref transaction that +/// deletes the branch. fn ensure_branch_safely_deletable(repo: &Path, branch: &str) -> Result { let refname = format!("refs/heads/{branch}"); let branch_oid = exact_ref_oid(repo, &refname)?; let upstream = branch_upstream(repo, &refname)?; - let (base_oid, baseline) = match upstream.as_deref() { - Some(upstream_ref) => { - let oid = exact_ref_oid(repo, upstream_ref).map_err(|_| { - format!( - "The upstream for branch '{branch}' is unavailable, so safe deletion could not be verified. Fetch or use Force delete." + let (base_oid, verifications) = match upstream.as_deref() { + Some(upstream_ref) => match exact_ref_oid_if_exists(repo, upstream_ref)? { + Some(oid) => ( + oid.clone(), + vec![(upstream_ref.to_string(), Some(oid))], + ), + None => { + let (head_oid, head_verification) = head_delete_baseline(repo)?; + ( + head_oid, + vec![head_verification, (upstream_ref.to_string(), None)], ) - })?; - (oid.clone(), Some((upstream_ref.to_string(), oid))) - } - None => { - let symbolic = Command::new("git") - .current_dir(repo) - .args(["symbolic-ref", "--quiet", "HEAD"]) - .output() - .map_err(|e| format!("Failed to inspect HEAD: {e}"))?; - if symbolic.status.success() { - let baseline_ref = str::from_utf8(&symbolic.stdout) - .map_err(|_| "Git returned an invalid HEAD reference.".to_string())? - .trim(); - if baseline_ref.is_empty() { - return Err("Git returned an empty HEAD reference.".to_string()); - } - let oid = exact_ref_oid(repo, baseline_ref)?; - (oid.clone(), Some((baseline_ref.to_string(), oid))) - } else { - // Detached HEAD is a direct ref, so verify-and-lock HEAD itself. - let output = Command::new("git") - .current_dir(repo) - .args(["rev-parse", "--verify", "HEAD^{commit}"]) - .output() - .map_err(|e| format!("Failed to inspect HEAD: {e}"))?; - if !output.status.success() { - return Err( - "HEAD is unavailable, so safe branch deletion could not be verified." - .to_string(), - ); - } - let oid = str::from_utf8(&output.stdout) - .map_err(|_| "Git returned an invalid HEAD object ID.".to_string())? - .trim(); - if oid.is_empty() { - return Err("Git returned an empty HEAD object ID.".to_string()); - } - let oid = oid.to_string(); - (oid.clone(), Some(("HEAD".to_string(), oid))) } + }, + None => { + let (head_oid, head_verification) = head_delete_baseline(repo)?; + (head_oid, vec![head_verification]) } }; @@ -415,7 +450,7 @@ fn ensure_branch_safely_deletable(repo: &Path, branch: &str) -> Result 0 + || final_status.unstaged > 0 + || final_status.untracked > 0 + || final_status.conflicted > 0 + || final_status.operation.is_some() + { + return Err( + "The worktree has uncommitted changes or an operation in progress. Commit, stash, or discard them before removing it." + .to_string(), + ); + } let mut remove = Command::new("git"); // Deliberately no --force: Git gets the final race-safe say and refuses if // files become dirty after the status snapshot above. @@ -1073,12 +1123,176 @@ mod tests { assert!(!config.status.success(), "deleted branch config must be removed"); } + #[test] + fn safe_delete_falls_back_to_head_when_configured_upstream_is_gone() { + let r = TempRepo::new(); + r.commit("a.txt", "A"); + let wt = r.path.join("merged-linked"); + r.git(&[ + "worktree", + "add", + "-q", + "-b", + "feature", + wt.to_str().unwrap(), + "main", + ]); + fs::write(wt.join("feature.txt"), "merged feature").unwrap(); + r.git(&["-C", wt.to_str().unwrap(), "add", "feature.txt"]); + r.git(&[ + "-C", + wt.to_str().unwrap(), + "commit", + "-q", + "-m", + "merged feature", + ]); + r.git(&["merge", "-q", "--ff-only", "feature"]); + r.git(&["remote", "add", "origin", "unused-test-remote"]); + r.git(&["config", "branch.feature.remote", "origin"]); + r.git(&[ + "config", + "branch.feature.merge", + "refs/heads/feature", + ]); + + assert_eq!( + branch_upstream(&r.path, "refs/heads/feature").unwrap().as_deref(), + Some("refs/remotes/origin/feature") + ); + assert!(!r.has_ref("refs/remotes/origin/feature")); + + delete_branch( + &r.path, + "feature", + false, + false, + None, + None, + Some(wt.to_str().unwrap()), + ) + .unwrap(); + + assert!(!wt.exists()); + assert!(!r.has_ref("refs/heads/feature")); + } + + #[test] + fn gone_upstream_fallback_still_refuses_an_unmerged_branch() { + let r = TempRepo::new(); + r.commit("a.txt", "A"); + let wt = r.path.join("unmerged-gone-upstream"); + r.git(&[ + "worktree", + "add", + "-q", + "-b", + "feature", + wt.to_str().unwrap(), + "main", + ]); + fs::write(wt.join("feature.txt"), "unmerged feature").unwrap(); + r.git(&["-C", wt.to_str().unwrap(), "add", "feature.txt"]); + r.git(&[ + "-C", + wt.to_str().unwrap(), + "commit", + "-q", + "-m", + "unmerged feature", + ]); + r.git(&["remote", "add", "origin", "unused-test-remote"]); + r.git(&["config", "branch.feature.remote", "origin"]); + r.git(&[ + "config", + "branch.feature.merge", + "refs/heads/feature", + ]); + + let err = delete_branch( + &r.path, + "feature", + false, + false, + None, + None, + Some(wt.to_str().unwrap()), + ) + .unwrap_err(); + + assert!(err.contains("not fully merged"), "unexpected error: {err}"); + assert!(wt.exists()); + assert!(r.has_ref("refs/heads/feature")); + } + + #[test] + fn live_upstream_remains_the_safe_delete_baseline() { + let r = TempRepo::new(); + r.commit("a.txt", "A"); + let upstream_oid = r.rev("main"); + let wt = r.path.join("live-upstream"); + r.git(&[ + "worktree", + "add", + "-q", + "-b", + "feature", + wt.to_str().unwrap(), + "main", + ]); + fs::write(wt.join("feature.txt"), "merged into HEAD only").unwrap(); + r.git(&["-C", wt.to_str().unwrap(), "add", "feature.txt"]); + r.git(&[ + "-C", + wt.to_str().unwrap(), + "commit", + "-q", + "-m", + "feature commit", + ]); + r.git(&["merge", "-q", "--ff-only", "feature"]); + r.git(&[ + "update-ref", + "refs/remotes/origin/feature", + &upstream_oid, + ]); + r.git(&["remote", "add", "origin", "unused-test-remote"]); + r.git(&["config", "branch.feature.remote", "origin"]); + r.git(&[ + "config", + "branch.feature.merge", + "refs/heads/feature", + ]); + + let err = delete_branch( + &r.path, + "feature", + false, + false, + None, + None, + Some(wt.to_str().unwrap()), + ) + .unwrap_err(); + + assert!(err.contains("not fully merged"), "unexpected error: {err}"); + assert!(wt.exists()); + assert!(r.has_ref("refs/heads/feature")); + } + #[test] fn prepared_branch_delete_blocks_concurrent_ref_movement_and_aborts_cleanly() { let r = TempRepo::new(); r.commit("a.txt", "A"); r.git(&["branch", "feature", "main"]); r.commit("b.txt", "B"); + r.git(&["remote", "add", "origin", "unused-test-remote"]); + r.git(&["config", "branch.feature.remote", "origin"]); + r.git(&[ + "config", + "branch.feature.merge", + "refs/heads/feature", + ]); let safety = ensure_branch_safely_deletable(&r.path, "feature").unwrap(); let expected = safety.branch_oid.clone(); let replacement = r.rev("main"); @@ -1087,10 +1301,7 @@ mod tests { &r.path, "refs/heads/feature", &expected, - safety - .baseline - .as_ref() - .map(|(refname, oid)| (refname.as_str(), oid.as_str())), + &safety.verifications, ) .unwrap(); let attempted_move = Command::new("git") @@ -1114,11 +1325,26 @@ mod tests { !attempted_baseline_rewind.status.success(), "prepared merge baseline must stay locked" ); + let attempted_upstream_creation = Command::new("git") + .current_dir(&r.path) + .args([ + "update-ref", + "refs/remotes/origin/feature", + &replacement, + ]) + .output() + .unwrap(); + assert!( + !attempted_upstream_creation.status.success(), + "prepared missing-upstream verification must keep the ref absent" + ); assert_eq!(r.rev("refs/heads/feature"), expected); assert_eq!(r.rev("refs/heads/main"), replacement); + assert!(!r.has_ref("refs/remotes/origin/feature")); drop(transaction); assert_eq!(r.rev("refs/heads/feature"), expected); assert!(!r.path.join(".git/refs/heads/feature.lock").exists()); + assert!(!r.path.join(".git/refs/remotes/origin/feature.lock").exists()); } #[test] @@ -1246,6 +1472,50 @@ mod tests { assert!(r.has_ref("refs/heads/feature")); } + #[test] + fn delete_branch_refuses_linked_worktree_with_active_bisect() { + let r = TempRepo::new(); + r.commit("a.txt", "A"); + let wt = r.path.join("bisect-linked"); + r.git(&[ + "worktree", + "add", + "-q", + "-b", + "feature", + wt.to_str().unwrap(), + "main", + ]); + r.git(&["-C", wt.to_str().unwrap(), "bisect", "start"]); + let admin_dir = git_path_output( + &wt, + &["rev-parse", "--absolute-git-dir"], + "test worktree administrative directory", + ) + .unwrap(); + assert!(admin_dir.join("BISECT_START").exists()); + + let err = delete_branch( + &r.path, + "feature", + true, + false, + None, + None, + Some(wt.to_str().unwrap()), + ) + .unwrap_err(); + + assert!( + err.contains("operation in progress"), + "unexpected error: {err}" + ); + assert!(wt.exists()); + assert!(r.has_ref("refs/heads/feature")); + assert!(admin_dir.join("BISECT_START").exists()); + assert!(!admin_dir.join("HEAD.lock").exists()); + } + #[test] fn delete_branch_refuses_locked_linked_worktree_without_mutation() { let r = TempRepo::new(); diff --git a/src/lib/gitActions.partialFailure.test.ts b/src/lib/gitActions.partialFailure.test.ts new file mode 100644 index 0000000..c070c9b --- /dev/null +++ b/src/lib/gitActions.partialFailure.test.ts @@ -0,0 +1,358 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { api } from "./api"; +import { dialogs } from "./dialogs.svelte"; +import { + gitActions, + loadMoreGraph, + refreshActiveRepo, + refreshRefs, + reloadGraph, +} from "./gitActions"; +import { SAMPLE_GRAPH } from "./graph/sample"; +import { appState } from "./store.svelte"; +import type { GraphCommit, Ref, RepoStatus } from "./types"; + +const repoStatus: RepoStatus = { + head: { sha: SAMPLE_GRAPH[0].sha, branch: "main", detached: false }, + staged: 0, + unstaged: 0, + untracked: 0, + conflicted: 0, + operation: null, +}; + +const mainRef: Ref = { + name: "main", + kind: "local", + target_sha: SAMPLE_GRAPH[0].sha, + upstream: null, + ahead: 0, + behind: 0, +}; + +function graphWithoutFeatureDecoration(): GraphCommit[] { + return SAMPLE_GRAPH.map((commit) => ({ + ...commit, + refs: commit.refs.filter((ref) => ref.name !== "feature/graph-view"), + })); +} + +function mockRefreshApis(refreshedGraph: GraphCommit[]) { + vi.spyOn(api, "loadGraph").mockResolvedValue(refreshedGraph); + vi.spyOn(api, "repoStatus").mockResolvedValue(repoStatus); + vi.spyOn(api, "workingChanges").mockResolvedValue([]); + vi.spyOn(api, "listRefs").mockResolvedValue([mainRef]); + vi.spyOn(api, "remotes").mockResolvedValue([]); + vi.spyOn(api, "listWorktrees").mockResolvedValue([]); +} + +beforeEach(() => { + vi.stubGlobal("window", { __TAURI_INTERNALS__: {} }); + appState.repo = "/tmp/git-it-partial-delete-test"; + appState.setRepoLoading(false); + appState.setGraphCommits(SAMPLE_GRAPH); + appState.setGraphHasMore(true); + appState.setRefsDetailed([ + mainRef, + { + name: "feature/graph-view", + kind: "local", + target_sha: SAMPLE_GRAPH[2].sha, + upstream: null, + ahead: 0, + behind: 0, + }, + ]); + appState.setCurrent(SAMPLE_GRAPH[2].sha); + appState.selected = new Set([SAMPLE_GRAPH[2].sha]); + appState.setNewDate(SAMPLE_GRAPH[2].sha, new Date("2020-01-01T00:00:00Z")); +}); + +afterEach(() => { + dialogs.resolveAlert(); + appState.repo = ""; + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("partial branch-delete refresh", () => { + it("removes stale graph decorations without discarding queued edits", async () => { + const refreshedGraph = graphWithoutFeatureDecoration(); + mockRefreshApis(refreshedGraph); + vi.spyOn(api, "deleteBranch").mockRejectedValue( + new Error("Deleted local branch, but remote delete failed"), + ); + + const ok = await gitActions.deleteBranch( + "feature/graph-view", + false, + true, + "origin", + "feature/graph-view", + ); + + expect(ok).toBe(false); + expect( + appState.graphCommits.some((commit) => + commit.refs.some((ref) => ref.name === "feature/graph-view"), + ), + ).toBe(false); + expect(appState.refsByKind.local.some((ref) => ref.name === "feature/graph-view")).toBe( + false, + ); + expect(appState.currentSha).toBe(SAMPLE_GRAPH[2].sha); + expect(appState.selected.has(SAMPLE_GRAPH[2].sha)).toBe(true); + expect(appState.newDates.has(SAMPLE_GRAPH[2].sha)).toBe(true); + }); + + it("discards an in-flight stale page before refreshing the failed operation", async () => { + let resolvePage: (commits: GraphCommit[]) => void = () => {}; + const page = new Promise((resolve) => { + resolvePage = resolve; + }); + let resolveRefresh: (commits: GraphCommit[]) => void = () => {}; + const refresh = new Promise((resolve) => { + resolveRefresh = resolve; + }); + const refreshedGraph = graphWithoutFeatureDecoration(); + const loadGraph = vi + .spyOn(api, "loadGraph") + .mockImplementationOnce(() => page) + .mockImplementationOnce(() => refresh); + vi.spyOn(api, "repoStatus").mockResolvedValue(repoStatus); + vi.spyOn(api, "workingChanges").mockResolvedValue([]); + vi.spyOn(api, "listRefs").mockResolvedValue([mainRef]); + vi.spyOn(api, "remotes").mockResolvedValue([]); + vi.spyOn(api, "listWorktrees").mockResolvedValue([]); + vi.spyOn(api, "deleteBranch").mockRejectedValue( + new Error("Deleted local branch, but remote delete failed"), + ); + + const loading = loadMoreGraph(); + await vi.waitFor(() => expect(loadGraph).toHaveBeenCalledTimes(1)); + const deleting = gitActions.deleteBranch( + "feature/graph-view", + false, + true, + "origin", + "feature/graph-view", + ); + resolvePage([ + { + ...SAMPLE_GRAPH[2], + sha: "stale-page-commit", + refs: [{ name: "feature/graph-view", kind: "local", is_head: false }], + }, + ]); + + await loading; + await vi.waitFor(() => expect(loadGraph).toHaveBeenCalledTimes(2)); + + // The stale page must be rejected as soon as it resolves, not merely + // overwritten later when the post-failure snapshot finishes. + expect(appState.graphCommits.some((commit) => commit.sha === "stale-page-commit")).toBe(false); + + resolveRefresh(refreshedGraph); + await deleting; + + expect(loadGraph).toHaveBeenCalledTimes(2); + expect(appState.graphCommits).toEqual(refreshedGraph); + expect(appState.graphCommits.some((commit) => commit.sha === "stale-page-commit")).toBe(false); + }); + + it("invalidates a stale page when an ordinary reload replaces the same-length graph", async () => { + let resolvePage: (commits: GraphCommit[]) => void = () => {}; + const page = new Promise((resolve) => { + resolvePage = resolve; + }); + let resolveReload: (commits: GraphCommit[]) => void = () => {}; + const reload = new Promise((resolve) => { + resolveReload = resolve; + }); + const refreshedGraph = graphWithoutFeatureDecoration(); + const loadGraph = vi + .spyOn(api, "loadGraph") + .mockImplementationOnce(() => page) + .mockImplementationOnce(() => reload); + vi.spyOn(api, "repoStatus").mockResolvedValue(repoStatus); + vi.spyOn(api, "workingChanges").mockResolvedValue([]); + vi.spyOn(api, "listRefs").mockResolvedValue([mainRef]); + vi.spyOn(api, "remotes").mockResolvedValue([]); + vi.spyOn(api, "listWorktrees").mockResolvedValue([]); + + const loading = loadMoreGraph(); + await vi.waitFor(() => expect(loadGraph).toHaveBeenCalledTimes(1)); + const reloading = reloadGraph(); + await vi.waitFor(() => expect(loadGraph).toHaveBeenCalledTimes(2)); + resolveReload(refreshedGraph); + await reloading; + + resolvePage([ + { + ...SAMPLE_GRAPH[2], + sha: "stale-page-after-reload", + refs: [{ name: "feature/graph-view", kind: "local", is_head: false }], + }, + ]); + await loading; + + expect(appState.graphCommits).toEqual(refreshedGraph); + expect(appState.graphCommits.some((commit) => commit.sha === "stale-page-after-reload")).toBe( + false, + ); + }); + + it("queues a watcher refresh that arrives while another preserving refresh is active", async () => { + let resolveFirstRefresh: (commits: GraphCommit[]) => void = () => {}; + const firstRefresh = new Promise((resolve) => { + resolveFirstRefresh = resolve; + }); + let resolveQueuedRefresh: (commits: GraphCommit[]) => void = () => {}; + const queuedRefresh = new Promise((resolve) => { + resolveQueuedRefresh = resolve; + }); + const refreshedGraph = graphWithoutFeatureDecoration(); + const loadGraph = vi + .spyOn(api, "loadGraph") + .mockImplementationOnce(() => firstRefresh) + .mockImplementationOnce(() => queuedRefresh); + vi.spyOn(api, "repoStatus").mockResolvedValue(repoStatus); + vi.spyOn(api, "workingChanges").mockResolvedValue([]); + vi.spyOn(api, "listRefs").mockResolvedValue([mainRef]); + vi.spyOn(api, "remotes").mockResolvedValue([]); + vi.spyOn(api, "listWorktrees").mockResolvedValue([]); + + refreshActiveRepo(); + await vi.waitFor(() => expect(loadGraph).toHaveBeenCalledTimes(1)); + refreshActiveRepo(); + // Let the second debounced request observe the still-active first refresh. + await new Promise((resolve) => setTimeout(resolve, 160)); + expect(loadGraph).toHaveBeenCalledTimes(1); + + resolveFirstRefresh(SAMPLE_GRAPH); + await vi.waitFor(() => expect(loadGraph).toHaveBeenCalledTimes(2)); + resolveQueuedRefresh(refreshedGraph); + + await vi.waitFor(() => expect(appState.graphCommits).toEqual(refreshedGraph)); + }); + + it("queues a watcher refresh that arrives during pagination", async () => { + let resolvePage: (commits: GraphCommit[]) => void = () => {}; + const page = new Promise((resolve) => { + resolvePage = resolve; + }); + let resolveQueuedRefresh: (commits: GraphCommit[]) => void = () => {}; + const queuedRefresh = new Promise((resolve) => { + resolveQueuedRefresh = resolve; + }); + const refreshedGraph = graphWithoutFeatureDecoration(); + const loadGraph = vi + .spyOn(api, "loadGraph") + .mockImplementationOnce(() => page) + .mockImplementationOnce(() => queuedRefresh); + vi.spyOn(api, "repoStatus").mockResolvedValue(repoStatus); + vi.spyOn(api, "workingChanges").mockResolvedValue([]); + vi.spyOn(api, "listRefs").mockResolvedValue([mainRef]); + vi.spyOn(api, "remotes").mockResolvedValue([]); + vi.spyOn(api, "listWorktrees").mockResolvedValue([]); + + const loading = loadMoreGraph(); + await vi.waitFor(() => expect(loadGraph).toHaveBeenCalledTimes(1)); + refreshActiveRepo(); + // Let the debounced watcher request observe the active page load. + await new Promise((resolve) => setTimeout(resolve, 160)); + expect(loadGraph).toHaveBeenCalledTimes(1); + + resolvePage([]); + await loading; + await vi.waitFor(() => expect(loadGraph).toHaveBeenCalledTimes(2)); + resolveQueuedRefresh(refreshedGraph); + + await vi.waitFor(() => expect(appState.graphCommits).toEqual(refreshedGraph)); + }); + + it("gives a full reload priority over a later preserving refresh", async () => { + let resolveReload: (commits: GraphCommit[]) => void = () => {}; + const reload = new Promise((resolve) => { + resolveReload = resolve; + }); + const refreshedGraph = graphWithoutFeatureDecoration(); + const loadGraph = vi + .spyOn(api, "loadGraph") + .mockImplementationOnce(() => reload) + .mockRejectedValueOnce(new Error("queued preserving refresh failed")); + vi.spyOn(api, "repoStatus").mockResolvedValue(repoStatus); + vi.spyOn(api, "workingChanges").mockResolvedValue([]); + vi.spyOn(api, "listRefs").mockResolvedValue([mainRef]); + vi.spyOn(api, "remotes").mockResolvedValue([]); + vi.spyOn(api, "listWorktrees").mockResolvedValue([]); + vi.spyOn(console, "warn").mockImplementation(() => {}); + appState.setRepoLoading(true); + + const reloading = reloadGraph(); + await vi.waitFor(() => expect(loadGraph).toHaveBeenCalledTimes(1)); + refreshActiveRepo(); + // A preserving load must not start early and invalidate the full reload. + await new Promise((resolve) => setTimeout(resolve, 160)); + expect(loadGraph).toHaveBeenCalledTimes(1); + + resolveReload(refreshedGraph); + await reloading; + + await vi.waitFor(() => expect(loadGraph).toHaveBeenCalledTimes(2)); + expect(appState.graphCommits).toEqual(refreshedGraph); + expect(appState.repoLoading).toBe(false); + }); + + it("queues a post-failure snapshot behind an older live refresh", async () => { + let resolveLiveRefresh: (commits: GraphCommit[]) => void = () => {}; + const liveRefresh = new Promise((resolve) => { + resolveLiveRefresh = resolve; + }); + const refreshedGraph = graphWithoutFeatureDecoration(); + const loadGraph = vi + .spyOn(api, "loadGraph") + .mockImplementationOnce(() => liveRefresh) + .mockResolvedValueOnce(refreshedGraph); + vi.spyOn(api, "repoStatus").mockResolvedValue(repoStatus); + vi.spyOn(api, "workingChanges").mockResolvedValue([]); + vi.spyOn(api, "listRefs").mockResolvedValue([mainRef]); + vi.spyOn(api, "remotes").mockResolvedValue([]); + vi.spyOn(api, "listWorktrees").mockResolvedValue([]); + vi.spyOn(api, "deleteBranch").mockRejectedValue( + new Error("Deleted local branch, but remote delete failed"), + ); + + refreshActiveRepo(); + await vi.waitFor(() => expect(loadGraph).toHaveBeenCalledTimes(1)); + const deleting = gitActions.deleteBranch( + "feature/graph-view", + false, + true, + "origin", + "feature/graph-view", + ); + resolveLiveRefresh(SAMPLE_GRAPH); + + await deleting; + + expect(loadGraph).toHaveBeenCalledTimes(2); + expect(appState.graphCommits).toEqual(refreshedGraph); + }); + + it("falls back to graph refs when the detailed inventory refresh fails", async () => { + appState.setRefsDetailed([mainRef]); + expect(appState.refsByKind.local.some((ref) => ref.name === "feature/graph-view")).toBe( + false, + ); + vi.spyOn(api, "listRefs").mockRejectedValue(new Error("list refs failed")); + vi.spyOn(api, "remotes").mockResolvedValue([]); + vi.spyOn(api, "listWorktrees").mockResolvedValue([]); + vi.spyOn(console, "warn").mockImplementation(() => {}); + + await refreshRefs(); + + expect(appState.refsDetailed).toEqual([]); + expect(appState.refsByKind.local.some((ref) => ref.name === "feature/graph-view")).toBe(true); + }); +}); diff --git a/src/lib/gitActions.ts b/src/lib/gitActions.ts index 4236204..70daae0 100644 --- a/src/lib/gitActions.ts +++ b/src/lib/gitActions.ts @@ -16,6 +16,16 @@ function isTauri(): boolean { const PAGE = 150; +// Coordinate preserving graph refreshes with incremental page loads. A failed +// operation may still have changed refs (for example, local delete succeeded but +// remote delete failed), so its refresh must supersede any page fetched from the +// pre-operation graph rather than letting stale decorations append afterward. +let graphRefreshGeneration = 0; +let graphPageLoadPromise: Promise | null = null; +let preservingGraphRefreshPromise: Promise | null = null; +let graphReloadPromise: Promise | null = null; +let preservingGraphRefreshQueued = false; + export async function refreshStatus(): Promise { if (!isTauri() || !appState.repo) { appState.setRepoStatus(null); @@ -49,8 +59,11 @@ export async function refreshRefs(): Promise { api.listWorktrees(target), ]); if (appState.repo !== target) return; - if (refs.status === "fulfilled") appState.setRefsDetailed(refs.value); - else console.warn("[gte] refs refresh failed", refs.reason); + if (refs.status === "fulfilled") appState.setRefsDetailed(refs.value, target); + else { + appState.invalidateRefsDetailed(target); + console.warn("[gte] refs refresh failed", refs.reason); + } if (remotes.status === "fulfilled") appState.setRemotes(remotes.value); else console.warn("[gte] remotes refresh failed", remotes.reason); if (worktrees.status === "fulfilled") appState.setWorktrees(worktrees.value); @@ -111,26 +124,99 @@ function scheduleLocalRefresh(): void { // applies it NON-destructively — preserving the open commit, multi-selection, // queued time-edits and scroll. It also skips while a page-load is in flight so a // background event can't interleave into a non-contiguous graph. -async function liveRefreshGraph(): Promise { - if (!isTauri() || !appState.repo) return; - if (appState.graphLoadingMore) return; // don't fight an in-flight loadMoreGraph - const target = appState.repo; - const count = Math.max(PAGE, appState.graphCommits.length); - let gc: GraphCommit[]; - try { - gc = await api.loadGraph(target, count, 0); - } catch (e) { - console.warn("[gte] live graph refresh failed", e); - return; // transient — keep the current view +function refreshGraphPreservingState(waitForPageLoad: boolean): Promise { + if (!isTauri() || !appState.repo) return Promise.resolve(); + if (graphReloadPromise) { + // A full reload owns the canonical graph replacement. A required refresh + // runs after it; ordinary watcher events coalesce into one queued snapshot. + if (waitForPageLoad) { + return graphReloadPromise.then(() => refreshGraphPreservingState(true)); + } + preservingGraphRefreshQueued = true; + return graphReloadPromise; } - if (appState.repo !== target) return; - appState.applyGraphRefresh(gc); - appState.setGraphHasMore(gc.length >= count); - await refreshStatus(); - if (appState.repo !== target) return; - await refreshWorkingChanges(); - if (appState.repo !== target) return; - await refreshRefs(); + if (preservingGraphRefreshPromise) { + // A failed op needs a snapshot requested after that op completed. If a + // watcher/focus refresh was already in flight, let it settle and then take + // another snapshot rather than accepting its potentially pre-op result. + if (waitForPageLoad) { + return preservingGraphRefreshPromise.then(() => refreshGraphPreservingState(true)); + } + // The active request may have captured its snapshot before this newer + // watcher/focus event. Remember one dirty bit so the event is not lost. + preservingGraphRefreshQueued = true; + return preservingGraphRefreshPromise; + } + if (appState.graphLoadingMore && !waitForPageLoad) { + preservingGraphRefreshQueued = true; + return graphPageLoadPromise ?? Promise.resolve(); + } + + const task = (async () => { + const target = appState.repo; + if (!target) return; + + if (appState.graphLoadingMore) { + // Invalidate the page before waiting: its response was requested against + // the old ref state and must not append even if the commit count is equal. + graphRefreshGeneration += 1; + const pendingPage = graphPageLoadPromise; + if (pendingPage) await pendingPage; + else { + while (appState.repo === target && appState.graphLoadingMore) { + await new Promise((resolve) => setTimeout(resolve, 20)); + } + } + } + if (appState.repo !== target) return; + + const generation = ++graphRefreshGeneration; + const count = Math.max(PAGE, appState.graphCommits.length); + let gc: GraphCommit[] | null = null; + try { + gc = await api.loadGraph(target, count, 0); + } catch (e) { + console.warn("[gte] preserving graph refresh failed", e); + } + if (appState.repo !== target || generation !== graphRefreshGeneration) return; + if (gc) { + appState.applyGraphRefresh(gc); + appState.setGraphHasMore(gc.length >= count); + } + await refreshStatus(); + if (appState.repo !== target) return; + await refreshWorkingChanges(); + if (appState.repo !== target) return; + await refreshRefs(); + })(); + + const tracked = task.finally(() => { + if (preservingGraphRefreshPromise === tracked) { + preservingGraphRefreshPromise = null; + runQueuedPreservingGraphRefresh(); + } + }); + preservingGraphRefreshPromise = tracked; + return tracked; +} + +function runQueuedPreservingGraphRefresh(): void { + if ( + !preservingGraphRefreshQueued || + preservingGraphRefreshPromise || + graphReloadPromise || + graphPageLoadPromise + ) { + return; + } + preservingGraphRefreshQueued = false; + void refreshGraphPreservingState(false).catch((e) => { + console.warn("[gte] queued graph refresh failed", e); + }); +} + +async function liveRefreshGraph(): Promise { + await refreshGraphPreservingState(false); } // Coalesced live refresh. Triggered by a "git" filesystem event (a commit/branch/ @@ -193,64 +279,98 @@ export async function reloadGraph(): Promise { // with a stale repo's (the flicker fix keeps the old data visible until here). const target = appState.repo; if (!target) return; - try { - let gc: GraphCommit[]; + const generation = ++graphRefreshGeneration; + const task = (async () => { try { - gc = await api.loadGraph(target, PAGE, 0); - } catch (e) { - // Load failed (repo moved / deleted / corrupt). Because the flicker fix keeps - // the PREVIOUS repo's data on screen until this point, we must clear it on - // failure for the still-current repo — otherwise one repo's history would show - // under another's name. Bail silently if a newer switch already superseded us. - if (appState.repo === target) { - appState.setGraphCommits([]); - appState.setGraphHasMore(false); - appState.setRepoStatus(null); - appState.setRefsDetailed([]); - appState.setWorktrees([]); - appState.setWorkingChanges([]); - appState.status = `Could not open ${target}: ${e}`; + let gc: GraphCommit[]; + try { + gc = await api.loadGraph(target, PAGE, 0); + } catch (e) { + // Load failed (repo moved / deleted / corrupt). Because the flicker fix keeps + // the PREVIOUS repo's data on screen until this point, we must clear it on + // failure for the still-current repo — otherwise one repo's history would show + // under another's name. Bail silently if a newer switch already superseded us. + if (appState.repo === target && generation === graphRefreshGeneration) { + // Any page requested while this reload was in flight was based on the + // failed snapshot and must not append after we clear the graph. + graphRefreshGeneration += 1; + appState.setGraphCommits([]); + appState.setGraphHasMore(false); + appState.setRepoStatus(null); + appState.setRefsDetailed([]); + appState.setWorktrees([]); + appState.setWorkingChanges([]); + appState.status = `Could not open ${target}: ${e}`; + } + return; } - return; + if (appState.repo !== target || generation !== graphRefreshGeneration) return; + // Invalidate pages that started before or during this reload. The first + // increment above rejects older requests; this one rejects requests made + // while loadGraph itself was still in flight. + graphRefreshGeneration += 1; + // Once the new repository's graph replaces the old one, old worktree paths + // must no longer remain visible or actionable while their refresh settles. + // Same-repository reloads keep the cached list on transient failures. + if (appState.repoLoading) appState.setWorktrees([]); + appState.setGraphCommits(gc); + appState.setGraphHasMore(gc.length === PAGE); + await refreshStatus(); + if (appState.repo !== target) return; + await refreshWorkingChanges(); + if (appState.repo !== target) return; + await refreshRefs(); + } finally { + // Clear the switch-in-progress flag (set by `set repo`) so remote actions + // re-enable — but only if we're still the current repo, so a superseding + // switch's own flag isn't cleared out from under it. + if (appState.repo === target) appState.setRepoLoading(false); } - if (appState.repo !== target) return; - // Once the new repository's graph replaces the old one, old worktree paths - // must no longer remain visible or actionable while their refresh settles. - // Same-repository reloads keep the cached list on transient failures. - if (appState.repoLoading) appState.setWorktrees([]); - appState.setGraphCommits(gc); - appState.setGraphHasMore(gc.length === PAGE); - await refreshStatus(); - if (appState.repo !== target) return; - await refreshWorkingChanges(); - if (appState.repo !== target) return; - await refreshRefs(); - } finally { - // Clear the switch-in-progress flag (set by `set repo`) so remote actions - // re-enable — but only if we're still the current repo, so a superseding - // switch's own flag isn't cleared out from under it. - if (appState.repo === target) appState.setRepoLoading(false); - } + })(); + const tracked = task.finally(() => { + if (graphReloadPromise === tracked) { + graphReloadPromise = null; + runQueuedPreservingGraphRefresh(); + } + }); + graphReloadPromise = tracked; + await tracked; } export async function loadMoreGraph(): Promise { if (!isTauri() || !appState.repo) return; - if (!appState.graphHasMore || appState.graphLoadingMore) return; + if (!appState.graphHasMore || appState.graphLoadingMore || preservingGraphRefreshPromise) return; appState.setGraphLoadingMore(true); + const target = appState.repo; const offset = appState.graphCommits.length; - try { - const gc = await api.loadGraph(appState.repo, PAGE, offset); - // If a live/switch refresh reset the list underneath us while this page was in - // flight, drop the now-stale page — appending it would leave a hole and corrupt - // the lane geometry. - if (appState.graphCommits.length !== offset) return; - appState.appendGraphCommits(gc); - appState.setGraphHasMore(gc.length === PAGE); - } catch (e) { - console.warn("[gte] load more failed", e); - } finally { - appState.setGraphLoadingMore(false); - } + const generation = graphRefreshGeneration; + const task = (async () => { + try { + const gc = await api.loadGraph(target, PAGE, offset); + // If a preserving refresh or repo switch superseded this request, discard + // its pre-refresh decorations even when the graph length stayed the same. + if ( + appState.repo !== target || + graphRefreshGeneration !== generation || + appState.graphCommits.length !== offset + ) + return; + appState.appendGraphCommits(gc); + appState.setGraphHasMore(gc.length === PAGE); + } catch (e) { + console.warn("[gte] load more failed", e); + } finally { + appState.setGraphLoadingMore(false); + } + })(); + const tracked = task.finally(() => { + if (graphPageLoadPromise === tracked) { + graphPageLoadPromise = null; + runQueuedPreservingGraphRefresh(); + } + }); + graphPageLoadPromise = tracked; + await tracked; } // Jump to a commit by SHA, paging in more history first if it isn't loaded yet. @@ -315,12 +435,14 @@ async function run(label: string, fn: () => Promise): Promise return true; } catch (e) { // A partial success (e.g. the local branch was deleted but its remote delete failed) - // should still update the sidebar — but use the NON-destructive refreshRefs(), not - // reloadGraph(). reloadGraph() resets graphCommits via setGraphCommits(), which clears - // the user's queued commit-time edits (newDates), selection, and currentSha; an - // unrelated failed op (rejected checkout, branch-already-exists, fetch error) must not - // silently discard those. refreshRefs() only re-reads the ref/remote lists. - try { await refreshRefs(); } catch (err) { console.warn("[gte] refs refresh after failed op", err); } + // should update every graph decoration as well as the sidebar. Use the + // preserving refresh: reloadGraph()/setGraphCommits() would clear queued + // commit-time edits, selection, and currentSha after an unrelated failure. + try { + await refreshGraphPreservingState(true); + } catch (err) { + console.warn("[gte] preserving graph refresh after failed op", err); + } appState.status = `${label} failed: ${firstLine(e)}`; // The status bar alone is too easy to miss for a discrete action the user just took // (user-reported "no feedback for if something went wrong") — surface it as a dialog. diff --git a/src/lib/store.svelte.ts b/src/lib/store.svelte.ts index 515b9fb..d622228 100644 --- a/src/lib/store.svelte.ts +++ b/src/lib/store.svelte.ts @@ -728,25 +728,40 @@ function makeState() { // list_refs) so tags/branches on commits not yet paged into the graph still show // up without scrolling, then overlaid with the loaded-graph decorations — which // are authoritative for is_head and supply the detached-HEAD pseudo-ref that - // list_refs doesn't return. In the browser preview refsDetailed is empty, so it + // list_refs doesn't return. When no inventory has been loaded for this repo, it // falls back to the graph decorations (keeps working without a list_refs call). const refsByKind = $derived.by(() => { const local = new Map(); const remote = new Map(); const tags = new Map(); const head: RefEntry[] = []; // detached-HEAD decoration (RefKind "head") - for (const r of refsDetailed) { - // Carry ahead/behind for local branches (used by the sidebar's ↑/↓ badges). - const entry: RefEntry = - r.kind === "local" - ? { name: r.name, sha: r.target_sha, isHead: false, ahead: r.ahead, behind: r.behind } - : { name: r.name, sha: r.target_sha, isHead: false }; - if (r.kind === "local") local.set(r.name, entry); - else if (r.kind === "remote") remote.set(r.name, entry); - else if (r.kind === "tag") tags.set(r.name, entry); + // list_refs is the complete inventory in the desktop app. Once it has loaded, + // graph decorations may enrich an existing ref but must not resurrect one + // that disappeared from a newer ref refresh. Browser preview has no detailed + // inventory, so it intentionally keeps the graph-only fallback below. + const hasDetailedInventory = refsDetailedRepo === repo; + if (hasDetailedInventory) { + for (const r of refsDetailed) { + // Carry ahead/behind for local branches (used by the sidebar's ↑/↓ badges). + const entry: RefEntry = + r.kind === "local" + ? { name: r.name, sha: r.target_sha, isHead: false, ahead: r.ahead, behind: r.behind } + : { name: r.name, sha: r.target_sha, isHead: false }; + if (r.kind === "local") local.set(r.name, entry); + else if (r.kind === "remote") remote.set(r.name, entry); + else if (r.kind === "tag") tags.set(r.name, entry); + } } for (const c of graphCommits) { for (const r of c.refs) { + if ( + hasDetailedInventory && + ((r.kind === "local" && !local.has(r.name)) || + (r.kind === "remote" && !remote.has(r.name)) || + (r.kind === "tag" && !tags.has(r.name))) + ) { + continue; + } // Graph decorations are authoritative for sha + is_head but carry no // ahead/behind — preserve those from the list_refs seed above (if any). const prev = r.kind === "local" ? local.get(r.name) : undefined; @@ -1589,6 +1604,11 @@ function makeState() { // remotes: the result of api.remotes (name + url). // remoteOpActive / remoteLog: live progress for an in-flight pull/push. let refsDetailed = $state([]); + // The repository for which refsDetailed is a complete, successfully loaded + // inventory. null means the retained array is only a cache and graph refs are + // the best available source; this also makes a successful empty inventory + // distinguishable from a failed or not-yet-run list_refs call. + let refsDetailedRepo = $state(null); let worktrees = $state([]); let remotesState = $state([]); let remoteOpActive = $state(false); @@ -1609,7 +1629,7 @@ function makeState() { refsByKind.local.find((r) => r.isHead)?.name ?? null, ); const currentUpstreamRef = $derived( - currentBranchName + currentBranchName && refsDetailedRepo === repo ? (refsDetailed.find((r) => r.kind === "local" && r.name === currentBranchName) ?? null) : null, ); @@ -1639,6 +1659,7 @@ function makeState() { currentSha = null; selected = new Set(); newDates = new Map(); + refsDetailedRepo = null; if (v === "") { // Closing the LAST repo: clear the displayed data so the empty state // shows — nothing will reload it. @@ -2213,10 +2234,14 @@ function makeState() { }, // ── Remote detailed refs + remotes (Phase 6) ────────────────────────────── get refsDetailed() { - return refsDetailed; + return refsDetailedRepo === repo ? refsDetailed : []; }, - setRefsDetailed(v: Ref[]) { + setRefsDetailed(v: Ref[], sourceRepo = repo) { refsDetailed = v; + refsDetailedRepo = sourceRepo; + }, + invalidateRefsDetailed(sourceRepo: string) { + if (repo === sourceRepo) refsDetailedRepo = null; }, get worktrees() { return worktrees; diff --git a/src/lib/store.viewmodel.test.ts b/src/lib/store.viewmodel.test.ts index 3dc8152..e4a5bdb 100644 --- a/src/lib/store.viewmodel.test.ts +++ b/src/lib/store.viewmodel.test.ts @@ -25,6 +25,7 @@ beforeEach(() => { appState.setActiveView("timeline"); appState.setCurrent(null); appState.selected = new Set(); + appState.setRefsDetailed([]); }); describe("Local Changes view-model", () => { @@ -124,6 +125,62 @@ describe("Local Changes view-model", () => { expect(appState.newDates.has("goneSha000000")).toBe(false); // pruned }); + it("does not resurrect a deleted branch from stale graph decorations", () => { + const mainSha = SAMPLE_GRAPH[0].sha; + const featureSha = SAMPLE_GRAPH[2].sha; + const mainRef = { + name: "main", + kind: "local" as const, + target_sha: mainSha, + upstream: null, + ahead: 0, + behind: 0, + }; + const featureRef = { + name: "feature/graph-view", + kind: "local" as const, + target_sha: featureSha, + upstream: null, + ahead: 0, + behind: 0, + }; + appState.setGraphCommits(SAMPLE_GRAPH); + appState.setRefsDetailed([mainRef, featureRef]); + appState.setCurrent(featureSha); + appState.selected = new Set([featureSha]); + appState.setNewDate(featureSha, new Date("2020-01-01T00:00:00Z")); + expect(appState.refsByKind.local.some((ref) => ref.name === featureRef.name)).toBe(true); + + // A local delete can succeed before the optional remote delete fails. The + // detailed-ref refresh is authoritative even while the graph still carries + // its pre-operation decoration. + appState.setRefsDetailed([mainRef]); + + expect(appState.refsByKind.local.some((ref) => ref.name === featureRef.name)).toBe(false); + expect(appState.currentSha).toBe(featureSha); + expect(appState.selected.has(featureSha)).toBe(true); + expect(appState.newDates.has(featureSha)).toBe(true); + }); + + it("treats a successfully loaded empty ref inventory as authoritative", () => { + appState.setGraphCommits(SAMPLE_GRAPH); + appState.setRefsDetailed([]); + + expect(appState.refsByKind.local).toEqual([]); + }); + + it("falls back to graph decorations after the current ref inventory is invalidated", () => { + appState.setGraphCommits(SAMPLE_GRAPH); + appState.setRefsDetailed([]); + expect(appState.refsByKind.local).toEqual([]); + + appState.invalidateRefsDetailed(""); + + expect( + appState.refsByKind.local.some((ref) => ref.name === "feature/graph-view"), + ).toBe(true); + }); + it("applyGraphRefresh clears the focus only when the open commit is gone", () => { appState.setGraphCommits(SAMPLE_GRAPH); appState.setCurrent(SAMPLE_GRAPH[0].sha); diff --git a/src/lib/worktrees.test.ts b/src/lib/worktrees.test.ts index 8696abc..70b75cc 100644 --- a/src/lib/worktrees.test.ts +++ b/src/lib/worktrees.test.ts @@ -43,6 +43,7 @@ describe("worktree helpers", () => { expect(worktreeIsDirty(worktree())).toBe(false); expect(worktreeIsDirty(worktree({ status: { ...worktree().status!, untracked: 1 } }))).toBe(true); expect(worktreeIsDirty(worktree({ status: { ...worktree().status!, operation: "rebase" } }))).toBe(true); + expect(worktreeIsDirty(worktree({ status: { ...worktree().status!, operation: "bisect" } }))).toBe(true); }); it("allows only a verified clean linked worktree to be removed", () => { @@ -55,6 +56,9 @@ describe("worktree helpers", () => { expect( worktreeRemovalBlocker(worktree({ status: { ...worktree().status!, unstaged: 1 } })), ).toContain("uncommitted changes"); + expect( + worktreeRemovalBlocker(worktree({ status: { ...worktree().status!, operation: "bisect" } })), + ).toContain("operation in progress"); }); it("prioritizes the most useful status label", () => { From 15e51c34e47d0ef765a63c783444dc6222f1809b Mon Sep 17 00:00:00 2001 From: Ash Shah <494shah@gmail.com> Date: Fri, 17 Jul 2026 17:37:44 -0700 Subject: [PATCH 18/20] feat: improve updates, worktrees, and repository creation --- crates/git-core/src/git_ops.rs | 209 +++++++++++++++++- crates/git-core/src/github/mod.rs | 53 +++++ crates/git-core/src/ops.rs | 186 ++++++++++++++++ crates/git-core/src/types.rs | 8 + src-tauri/src/commands.rs | 27 ++- src-tauri/src/lib.rs | 34 ++- src/lib/api.ts | 21 +- src/lib/components/ContextMenu.svelte | 10 + src/lib/components/GraphHistory.svelte | 13 +- src/lib/components/Modal.releaseNotes.test.ts | 65 ++++++ src/lib/components/Modal.svelte | 140 +++++++++++- src/lib/components/RepoList.svelte | 20 +- src/lib/components/RepoTabs.svelte | 20 +- src/lib/components/Sidebar.svelte | 22 +- src/lib/components/StatusBar.svelte | 4 +- src/lib/components/WorktreePanel.svelte | 95 +++++++- src/lib/components/WorktreePanel.test.ts | 89 ++++++++ src/lib/contextMenu.svelte.ts | 1 + src/lib/dialogs.svelte.ts | 52 +++++ src/lib/gitActions.ts | 8 + src/lib/repositoryFlows.test.ts | 165 ++++++++++++++ src/lib/repositoryFlows.ts | 131 +++++++++++ src/lib/store.svelte.ts | 46 +++- src/lib/types.ts | 6 + src/lib/updater.svelte.ts | 178 +++++++++++---- src/lib/updater.test.ts | 145 ++++++++++++ src/lib/worktrees.test.ts | 33 ++- src/lib/worktrees.ts | 61 ++++- src/routes/+page.svelte | 47 ++-- 29 files changed, 1767 insertions(+), 122 deletions(-) create mode 100644 src/lib/components/Modal.releaseNotes.test.ts create mode 100644 src/lib/components/WorktreePanel.test.ts create mode 100644 src/lib/repositoryFlows.test.ts create mode 100644 src/lib/repositoryFlows.ts create mode 100644 src/lib/updater.test.ts diff --git a/crates/git-core/src/git_ops.rs b/crates/git-core/src/git_ops.rs index 62307ac..4261a24 100644 --- a/crates/git-core/src/git_ops.rs +++ b/crates/git-core/src/git_ops.rs @@ -1,4 +1,6 @@ -use crate::types::{BundleInfo, Commit, PrerequisiteCheck, SafetyRef}; +use crate::types::{ + BundleInfo, Commit, InitializeRepositoryResult, PrerequisiteCheck, SafetyRef, +}; use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; @@ -24,6 +26,121 @@ pub fn is_git_repo(repo: &Path) -> bool { repo.join(".git").exists() } +fn is_inside_worktree(path: &Path) -> bool { + Command::new("git") + .current_dir(path) + .args(["rev-parse", "--is-inside-work-tree"]) + .output() + .ok() + .filter(|output| output.status.success()) + .map(|output| String::from_utf8_lossy(&output.stdout).trim() == "true") + .unwrap_or(false) +} + +/// Initialize a repository in one direct child of an existing parent folder. +/// A non-empty destination is reported without mutation until the caller +/// explicitly retries with `allow_non_empty`. +pub fn initialize_repository( + parent: &Path, + folder_name: &str, + initial_branch: &str, + allow_non_empty: bool, +) -> Result { + let parent = fs::canonicalize(parent) + .map_err(|error| format!("Could not open the parent folder: {error}"))?; + if !parent.is_dir() { + return Err("The selected parent path is not a folder.".to_string()); + } + + let name = folder_name.trim(); + if name.is_empty() + || name != folder_name + || name == "." + || name == ".." + || name.contains('/') + || name.contains('\\') + { + return Err( + "Repository name must be a single folder name without path separators." + .to_string(), + ); + } + let branch = initial_branch.trim(); + if branch.is_empty() || branch != initial_branch || branch.starts_with('-') { + return Err("Initial branch name is invalid.".to_string()); + } + let branch_check = Command::new("git") + .current_dir(&parent) + .args(["check-ref-format", "--branch", branch]) + .output() + .map_err(|error| format!("Could not validate the initial branch: {error}"))?; + if !branch_check.status.success() { + return Err("Initial branch name is invalid.".to_string()); + } + + let destination = parent.join(name); + if fs::symlink_metadata(&destination) + .map(|metadata| metadata.file_type().is_symlink()) + .unwrap_or(false) + { + return Err( + "The requested repository path is a symbolic link. Choose the real folder instead." + .to_string(), + ); + } + if destination.exists() && !destination.is_dir() { + return Err("A file already exists at the requested repository path.".to_string()); + } + if destination.join(".git").exists() { + return Err("That folder is already a Git repository. Open it instead.".to_string()); + } + let nesting_probe = if destination.is_dir() { + &destination + } else { + &parent + }; + if is_inside_worktree(nesting_probe) { + return Err( + "Cannot create a nested repository inside another Git worktree. Choose a different parent folder." + .to_string(), + ); + } + + let existing_entries = if destination.is_dir() { + fs::read_dir(&destination) + .map_err(|error| format!("Could not inspect the destination folder: {error}"))? + .try_fold(0usize, |count, entry| entry.map(|_| count + 1)) + .map_err(|error| format!("Could not inspect the destination folder: {error}"))? + } else { + 0 + }; + let planned_path = destination.to_string_lossy().into_owned(); + if existing_entries > 0 && !allow_non_empty { + return Ok(InitializeRepositoryResult { + path: planned_path, + initialized: false, + existing_entries, + }); + } + + if !destination.exists() { + fs::create_dir(&destination) + .map_err(|error| format!("Could not create the repository folder: {error}"))?; + } + let mut init = Command::new("git"); + init.current_dir(&destination) + .arg("init") + .arg(format!("--initial-branch={branch}")); + run(&mut init)?; + let canonical = fs::canonicalize(&destination) + .map_err(|error| format!("Could not resolve the new repository path: {error}"))?; + Ok(InitializeRepositoryResult { + path: canonical.to_string_lossy().into_owned(), + initialized: true, + existing_entries, + }) +} + pub fn load_commits(repo: &Path, count: u32, range: Option<&str>) -> Result, String> { // Replicates: git log --no-decorate --pretty='%H%x1f%aN%x1f%aI%x1f%cN%x1f%cI%x1f%s%x1e' -n N [RANGE] // The \x1f field separator and \x1e record separator match the Python implementation. @@ -218,3 +335,93 @@ pub fn check_prerequisites(filter_repo_argv: &[String]) -> PrerequisiteCheck { filter_repo_version, } } + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + struct TempFolder(PathBuf); + + impl TempFolder { + fn new() -> Self { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "git-it-init-test-{}-{stamp}", + std::process::id() + )); + fs::create_dir(&path).unwrap(); + Self(path) + } + } + + impl Drop for TempFolder { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + #[test] + fn initialize_repository_creates_requested_folder_and_branch() { + let parent = TempFolder::new(); + + let result = initialize_repository(&parent.0, "project", "main", false).unwrap(); + + assert!(result.initialized); + assert_eq!(result.existing_entries, 0); + assert!(Path::new(&result.path).join(".git").is_dir()); + let branch = Command::new("git") + .current_dir(&result.path) + .args(["symbolic-ref", "--short", "HEAD"]) + .output() + .unwrap(); + assert!(branch.status.success()); + assert_eq!(String::from_utf8_lossy(&branch.stdout).trim(), "main"); + } + + #[test] + fn initialize_repository_requires_confirmation_for_existing_files() { + let parent = TempFolder::new(); + let destination = parent.0.join("project"); + fs::create_dir(&destination).unwrap(); + fs::write(destination.join("keep.txt"), "preserve me").unwrap(); + + let preview = initialize_repository(&parent.0, "project", "main", false).unwrap(); + assert!(!preview.initialized); + assert_eq!(preview.existing_entries, 1); + assert!(!destination.join(".git").exists()); + + let result = initialize_repository(&parent.0, "project", "main", true).unwrap(); + assert!(result.initialized); + assert_eq!( + fs::read_to_string(destination.join("keep.txt")).unwrap(), + "preserve me" + ); + } + + #[test] + fn initialize_repository_rejects_invalid_branch_before_mutation() { + let parent = TempFolder::new(); + + let err = initialize_repository(&parent.0, "project", "-danger", false).unwrap_err(); + + assert!(err.contains("branch name")); + assert!(!parent.0.join("project").exists()); + } + + #[test] + fn initialize_repository_rejects_nested_git_worktree() { + let parent = TempFolder::new(); + let mut init = Command::new("git"); + init.current_dir(&parent.0).args(["init", "-q"]); + run(&mut init).unwrap(); + + let err = initialize_repository(&parent.0, "nested", "main", false).unwrap_err(); + + assert!(err.contains("nested repository")); + assert!(!parent.0.join("nested").exists()); + } +} diff --git a/crates/git-core/src/github/mod.rs b/crates/git-core/src/github/mod.rs index 8777e99..e642b4f 100644 --- a/crates/git-core/src/github/mod.rs +++ b/crates/git-core/src/github/mod.rs @@ -955,6 +955,15 @@ pub fn issue_create(repo: &Path, title: &str, body: &str) -> Result Result { + let head = Command::new("git") + .current_dir(repo) + .args(["rev-parse", "--verify", "--quiet", "HEAD^{commit}"]) + .output() + .map_err(|e| GithubError::Other(format!("could not inspect HEAD: {e}")))?; + Ok(head.status.success()) +} + /// Create a GitHub repo from this local repo, wire it as `origin`, and push the /// current branch (setting upstream). TWO steps, deliberately not gh's `--push`: /// (1) `gh repo create --source= --private|--public @@ -997,6 +1006,18 @@ pub fn create_repo( // Step 1 — create the repo + add `origin` (gh API token; reliable). let created = run_gh(&args, None)?; + // A freshly initialized repository has an unborn HEAD. The remote and + // origin are already fully created in that state, but there is no ref Git + // can push yet (`src refspec HEAD does not match any`). Treat that as a + // successful empty-repository setup and let the first normal push establish + // the upstream after the user creates a commit. + if !has_committed_head(repo)? { + return Ok(format!( + "{}\nRemote added. Create the first commit to push this repository.", + created.trim() + )); + } + // Step 2 — push the current branch + set upstream. The empty `credential.helper=` // resets any inherited (possibly broken) global helpers; the second entry forces // gh's own credential helper so auth uses the gh token. GIT_TERMINAL_PROMPT=0 so a @@ -1667,6 +1688,8 @@ pub fn runs(repo: &Path, limit: u32) -> Result, GithubError> { #[cfg(test)] mod tests { use super::*; + use std::fs; + use std::time::{SystemTime, UNIX_EPOCH}; #[test] fn merge_flag_maps_known_methods() { @@ -1676,6 +1699,36 @@ mod tests { assert_eq!(merge_flag("bogus"), None); } + #[test] + fn committed_head_detection_distinguishes_an_empty_repository() { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "git-it-github-head-test-{}-{stamp}", + std::process::id() + )); + fs::create_dir(&path).unwrap(); + let run_git = |args: &[&str]| { + let output = Command::new("git") + .current_dir(&path) + .args(args) + .output() + .unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + }; + run_git(&["init", "-q", "--initial-branch=main"]); + assert!(!has_committed_head(&path).unwrap()); + run_git(&["config", "user.name", "Test"]); + run_git(&["config", "user.email", "test@example.com"]); + fs::write(path.join("a.txt"), "a").unwrap(); + run_git(&["add", "a.txt"]); + run_git(&["commit", "-q", "-m", "initial"]); + assert!(has_committed_head(&path).unwrap()); + let _ = fs::remove_dir_all(path); + } + #[test] fn parses_all_github_url_forms() { let cases = [ diff --git a/crates/git-core/src/ops.rs b/crates/git-core/src/ops.rs index 36b1783..5d89f9c 100644 --- a/crates/git-core/src/ops.rs +++ b/crates/git-core/src/ops.rs @@ -686,6 +686,127 @@ fn remove_linked_worktree_for_branch( Ok(worktree.path) } +fn validate_standalone_worktree_removal(worktree: &WorktreeInfo) -> Result<(), String> { + if worktree.is_current { + return Err( + "Cannot remove the current worktree. Open this repository from another worktree first." + .to_string(), + ); + } + if worktree.is_main { + return Err("Cannot remove the repository's primary checkout.".to_string()); + } + if worktree.bare || worktree.detached || worktree.branch.is_none() { + return Err( + "Cannot safely remove a detached or unborn worktree because its commits may not be referenced by a branch." + .to_string(), + ); + } + if worktree.locked { + let detail = worktree + .locked_reason + .as_deref() + .map(|reason| format!(": {reason}")) + .unwrap_or_default(); + return Err(format!( + "The worktree is locked{detail}. Unlock it before removing it." + )); + } + if worktree.prunable { + return Err( + "The worktree is stale or missing. Prune its Git metadata before removing it." + .to_string(), + ); + } + let status = worktree.status.as_ref().ok_or_else(|| { + "Could not verify that the worktree is clean, so it was not removed.".to_string() + })?; + if status.staged > 0 + || status.unstaged > 0 + || status.untracked > 0 + || status.conflicted > 0 + || status.operation.is_some() + { + return Err( + "The worktree has uncommitted changes or an operation in progress. Commit, stash, or discard them before removing it." + .to_string(), + ); + } + Ok(()) +} + +/// Remove one clean linked worktree while preserving its branch and branch +/// configuration. The path returned by Git's authoritative worktree listing is +/// always used for deletion; the caller-provided path is only an identity key. +pub fn remove_worktree(repo: &Path, requested_path: &str) -> Result { + let initial_matches: Vec = list_worktrees(repo)? + .into_iter() + .filter(|worktree| { + same_existing_path(Path::new(&worktree.path), Path::new(requested_path)) + }) + .collect(); + if initial_matches.len() != 1 { + return Err( + "The requested worktree is no longer registered. Refresh and try again." + .to_string(), + ); + } + let initial = &initial_matches[0]; + validate_standalone_worktree_removal(initial)?; + + // Freeze HEAD before the authoritative second listing. This prevents a + // concurrent checkout, commit, reset, or merge from repurposing the target + // between validation and `git worktree remove`. + let _target_head_lock = acquire_worktree_head_lock(repo, Path::new(&initial.path))?; + let matches: Vec = list_worktrees(repo)? + .into_iter() + .filter(|worktree| { + same_existing_path(Path::new(&worktree.path), Path::new(requested_path)) + }) + .collect(); + if matches.len() != 1 { + return Err( + "The requested worktree changed while removal was being prepared. Refresh and try again." + .to_string(), + ); + } + let worktree = &matches[0]; + validate_standalone_worktree_removal(worktree)?; + + let authoritative = PathBuf::from(&worktree.path); + if !authoritative.is_absolute() { + return Err( + "Git returned a non-absolute worktree path; refusing to remove it.".to_string(), + ); + } + // HEAD.lock does not cover marker-only operations such as `git bisect + // start`, so take one final status snapshot immediately before deletion. + let final_status = graph::repo_status(&authoritative).map_err(|_| { + "Could not reverify that the worktree is clean, so it was not removed.".to_string() + })?; + if final_status.staged > 0 + || final_status.unstaged > 0 + || final_status.untracked > 0 + || final_status.conflicted > 0 + || final_status.operation.is_some() + { + return Err( + "The worktree has uncommitted changes or an operation in progress. Commit, stash, or discard them before removing it." + .to_string(), + ); + } + + let mut remove = Command::new("git"); + // Never pass --force: Git remains the final authority if the filesystem + // changes after our last snapshot. + remove + .current_dir(repo) + .args(["worktree", "remove", "--"]) + .arg(&authoritative); + git_ops::run(&mut remove)?; + Ok(worktree.path.clone()) +} + /// Switch to a branch, or check out a commit (detached HEAD). Git refuses if the /// working tree has conflicting local changes; that refusal surfaces as Err. pub fn checkout(repo: &Path, target: &str) -> Result { @@ -1123,6 +1244,71 @@ mod tests { assert!(!config.status.success(), "deleted branch config must be removed"); } + #[test] + fn remove_worktree_preserves_its_branch_and_config() { + let r = TempRepo::new(); + r.commit("a.txt", "A"); + let wt = r.path.join("linked"); + r.git(&[ + "worktree", + "add", + "-q", + "-b", + "feature", + wt.to_str().unwrap(), + "main", + ]); + r.git(&["config", "branch.feature.description", "keep me"]); + let canonical_wt = fs::canonicalize(&wt).unwrap(); + + let removed = remove_worktree(&r.path, wt.to_str().unwrap()).unwrap(); + + assert_eq!(Path::new(&removed), canonical_wt); + assert!(!wt.exists()); + assert!(r.has_ref("refs/heads/feature")); + let description = Command::new("git") + .current_dir(&r.path) + .args(["config", "--get", "branch.feature.description"]) + .output() + .unwrap(); + assert!(description.status.success()); + assert_eq!(String::from_utf8_lossy(&description.stdout).trim(), "keep me"); + } + + #[test] + fn remove_worktree_refuses_dirty_linked_checkout() { + let r = TempRepo::new(); + r.commit("a.txt", "A"); + let wt = r.path.join("linked"); + r.git(&[ + "worktree", + "add", + "-q", + "-b", + "feature", + wt.to_str().unwrap(), + "main", + ]); + fs::write(wt.join("untracked.txt"), "do not delete").unwrap(); + + let err = remove_worktree(&r.path, wt.to_str().unwrap()).unwrap_err(); + + assert!(err.contains("uncommitted changes"), "unexpected error: {err}"); + assert!(wt.join("untracked.txt").exists()); + assert!(r.has_ref("refs/heads/feature")); + } + + #[test] + fn remove_worktree_never_removes_the_primary_checkout() { + let r = TempRepo::new(); + r.commit("a.txt", "A"); + + let err = remove_worktree(&r.path, r.path.to_str().unwrap()).unwrap_err(); + + assert!(err.contains("current worktree") || err.contains("primary checkout")); + assert!(r.path.join("a.txt").exists()); + } + #[test] fn safe_delete_falls_back_to_head_when_configured_upstream_is_gone() { let r = TempRepo::new(); diff --git a/crates/git-core/src/types.rs b/crates/git-core/src/types.rs index a0d8ca7..c7c01d5 100644 --- a/crates/git-core/src/types.rs +++ b/crates/git-core/src/types.rs @@ -111,6 +111,14 @@ pub struct RepoStatus { pub operation: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InitializeRepositoryResult { + pub path: String, + pub initialized: bool, + pub existing_entries: usize, +} + /// One entry from `git worktree list --porcelain`, enriched with status for /// worktrees that still exist on disk. Paths remain the authoritative strings /// emitted by Git so a later destructive request can be revalidated exactly. diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 75841ce..97bbe89 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -7,7 +7,12 @@ use git_core::ops_remote; use git_core::ops_rewrite; use git_core::ops_worktree; use git_core::rewrite; -use git_core::types::{BundleInfo, Commit, ConflictEntry, DateMapping, GraphCommit, OpOutcome, PrerequisiteCheck, RebaseOutcome, RebaseStep, Ref, ReflogEntry, RemoteInfo, RemoteOutcome, RepoStatus, RewriteOptions, RewriteResult, SafetyRef, StashEntry, WorkingFile, WorktreeInfo}; +use git_core::types::{ + BundleInfo, Commit, ConflictEntry, DateMapping, GraphCommit, InitializeRepositoryResult, + OpOutcome, PrerequisiteCheck, RebaseOutcome, RebaseStep, Ref, ReflogEntry, RemoteInfo, + RemoteOutcome, RepoStatus, RewriteOptions, RewriteResult, SafetyRef, StashEntry, WorkingFile, + WorktreeInfo, +}; use std::path::PathBuf; use std::sync::Arc; use tauri::ipc::Channel; @@ -58,6 +63,21 @@ pub fn is_git_repo(repo: String) -> bool { git_ops::is_git_repo(&PathBuf::from(repo)) } +#[tauri::command(async)] +pub fn initialize_repository( + parent: String, + folder_name: String, + initial_branch: String, + allow_non_empty: bool, +) -> Result { + git_ops::initialize_repository( + &PathBuf::from(parent), + &folder_name, + &initial_branch, + allow_non_empty, + ) +} + #[tauri::command] pub fn load_commits( repo: String, @@ -164,6 +184,11 @@ pub fn list_worktrees(repo: String) -> Result, String> { ops::list_worktrees(&PathBuf::from(repo)) } +#[tauri::command(async)] +pub fn remove_worktree(repo: String, path: String) -> Result { + ops::remove_worktree(&PathBuf::from(repo), &path) +} + #[tauri::command] pub fn repo_status(repo: String) -> Result { graph::repo_status(&PathBuf::from(repo)) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c4a994f..1a630e8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -59,7 +59,7 @@ pub fn run() { // Settings panel instead. #[cfg(target_os = "macos")] { - use tauri::menu::{Menu, MenuItem}; + use tauri::menu::{Menu, MenuItem, PredefinedMenuItem}; use tauri::Emitter; let menu = Menu::default(app.handle())?; let settings = MenuItem::with_id( @@ -76,12 +76,36 @@ pub fn run() { true, None::<&str>, )?; + let new_repository = MenuItem::with_id( + app.handle(), + "new-repository", + "New Repository…", + true, + Some("CmdOrCtrl+N"), + )?; + let open_repository = MenuItem::with_id( + app.handle(), + "open-repository", + "Open Repository…", + true, + Some("CmdOrCtrl+O"), + )?; + let file_separator = PredefinedMenuItem::separator(app.handle())?; let items = menu.items()?; if let Some(app_menu) = items.first().and_then(|item| item.as_submenu()) { // Insert just under "About" (index 0): Settings…, then Check for Updates… app_menu.insert(&settings, 1)?; app_menu.insert(&check_updates, 2)?; } + if let Some(file_menu) = items + .iter() + .filter_map(|item| item.as_submenu()) + .find(|submenu| submenu.text().ok().as_deref() == Some("File")) + { + file_menu.insert(&new_repository, 0)?; + file_menu.insert(&open_repository, 1)?; + file_menu.insert(&file_separator, 2)?; + } app.set_menu(menu)?; app.on_menu_event(|app_handle, event| match event.id().as_ref() { "open-settings" => { @@ -90,6 +114,12 @@ pub fn run() { "check-updates" => { let _ = app_handle.emit("menu:check-updates", ()); } + "new-repository" => { + let _ = app_handle.emit("menu:new-repository", ()); + } + "open-repository" => { + let _ = app_handle.emit("menu:open-repository", ()); + } _ => {} }); } @@ -112,10 +142,12 @@ pub fn run() { commands::check_prerequisites, commands::install_command_line_tools, commands::is_git_repo, + commands::initialize_repository, commands::load_commits, commands::load_graph, commands::list_refs, commands::list_worktrees, + commands::remove_worktree, commands::repo_status, commands::checkout, commands::create_branch, diff --git a/src/lib/api.ts b/src/lib/api.ts index 1de32e6..7729629 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -41,14 +41,18 @@ import type { UpdateInfo, DownloadEvent, WorktreeInfo, + InitializeRepositoryResult, } from "./types"; -export async function pickRepoFolder(initial?: string): Promise { +export async function pickRepoFolder( + initial?: string, + title = "Select git repository root", +): Promise { const result = await open({ directory: true, multiple: false, defaultPath: initial, - title: "Select git repository root", + title, }); return typeof result === "string" ? result : null; } @@ -57,6 +61,17 @@ export const api = { checkPrerequisites: () => invoke("check_prerequisites"), installCommandLineTools: () => invoke("install_command_line_tools"), isGitRepo: (repo: string) => invoke("is_git_repo", { repo }), + initializeRepository: ( + parent: string, + folderName: string, + initialBranch: string, + allowNonEmpty = false, + ) => invoke("initialize_repository", { + parent, + folderName, + initialBranch, + allowNonEmpty, + }), // ── auto-updater (desktop only) ────────────────────────────────────────── checkUpdateOnChannel: (channel: "stable" | "beta") => invoke("check_update_on_channel", { channel }), @@ -71,6 +86,8 @@ export const api = { invoke("load_graph", { repo, count, skip }), listRefs: (repo: string) => invoke("list_refs", { repo }), listWorktrees: (repo: string) => invoke("list_worktrees", { repo }), + removeWorktree: (repo: string, path: string) => + invoke("remove_worktree", { repo, path }), repoStatus: (repo: string) => invoke("repo_status", { repo }), checkout: (repo: string, target: string) => invoke("checkout", { repo, target }), createBranch: (repo: string, name: string, startPoint: string) => diff --git a/src/lib/components/ContextMenu.svelte b/src/lib/components/ContextMenu.svelte index 648c7e1..42fab8b 100644 --- a/src/lib/components/ContextMenu.svelte +++ b/src/lib/components/ContextMenu.svelte @@ -46,6 +46,8 @@ {#each contextMenu.items as item, i (i)} {#if item.separator} + {:else if item.detail} +
{item.label}
{:else if item.submenu}
+ {:else} + Open or create a repository to get started. + {/if} +
{/if} @@ -840,6 +847,10 @@ font-size: 12px; } .empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; text-align: center; color: var(--text-muted); padding: 24px; diff --git a/src/lib/components/Modal.releaseNotes.test.ts b/src/lib/components/Modal.releaseNotes.test.ts new file mode 100644 index 0000000..6c61696 --- /dev/null +++ b/src/lib/components/Modal.releaseNotes.test.ts @@ -0,0 +1,65 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from "vitest"; +import { mount, tick, unmount } from "svelte"; +import { dialogs } from "../dialogs.svelte"; +import Modal from "./Modal.svelte"; + +afterEach(() => { + if (dialogs.state.kind === "confirm") dialogs.resolveConfirm(false); + document.body.replaceChildren(); +}); + +describe("confirmation message rendering", () => { + it("renders opted-in updater notes as structured, scrollable markdown", async () => { + const target = document.createElement("div"); + document.body.appendChild(target); + const modal = mount(Modal, { target }); + const pending = dialogs.confirm({ + title: "Update available — 0.3.0-next.20", + message: [ + "Git It 0.3.0-next.20 is available.", + "", + "## Git It 0.3.0-next.20", + "", + "### ✨ New features", + "", + "- **General** — Add worktree visibility", + "- **UI** — Render release notes", + ].join("\n"), + messageFormat: "markdown", + confirmLabel: "Download", + }); + await tick(); + + const notes = target.querySelector("[data-testid='release-notes']"); + expect(notes).not.toBeNull(); + expect(notes?.querySelector("h2")?.textContent).toBe("Git It 0.3.0-next.20"); + expect(notes?.querySelector("h3")?.textContent).toContain("New features"); + expect(notes?.querySelectorAll("li")).toHaveLength(2); + expect(notes?.querySelector("strong")?.textContent).toBe("General"); + expect(notes?.textContent).not.toContain("## Git It"); + + dialogs.resolveConfirm(false); + await pending; + await unmount(modal); + }); + + it("keeps ordinary confirmation messages literal by default", async () => { + const target = document.createElement("div"); + document.body.appendChild(target); + const modal = mount(Modal, { target }); + const pending = dialogs.confirm({ + title: "Delete branch", + message: "Delete **feature/_literal_**?", + }); + await tick(); + + expect(target.querySelector("[data-testid='release-notes']")).toBeNull(); + expect(target.querySelector(".msg")?.textContent).toBe("Delete **feature/_literal_**?"); + expect(target.querySelector(".msg strong")).toBeNull(); + + dialogs.resolveConfirm(false); + await pending; + await unmount(modal); + }); +}); diff --git a/src/lib/components/Modal.svelte b/src/lib/components/Modal.svelte index 6aaacfa..9cfe69a 100644 --- a/src/lib/components/Modal.svelte +++ b/src/lib/components/Modal.svelte @@ -1,12 +1,14 @@
@@ -27,15 +105,16 @@ class="worktree" class:current={worktree.isCurrent} role="listitem" - title={`${label(worktree)} — ${worktree.path}`} + title={`${label(worktree)} — ${worktree.path}\n${worktreeStatusDetail(worktree)}${worktreeTrackingDetail(worktree, refs) ? `\n${worktreeTrackingDetail(worktree, refs)}` : ""}`} + oncontextmenu={(event) => openContextMenu(event, worktree)} > {label(worktree)} {worktree.path} - {#if worktreeStatusLabel(worktree)} - {worktreeStatusLabel(worktree)} + {#if rowState(worktree)} + {rowState(worktree)} {/if}
{/each} diff --git a/src/lib/components/WorktreePanel.test.ts b/src/lib/components/WorktreePanel.test.ts new file mode 100644 index 0000000..d50ba9b --- /dev/null +++ b/src/lib/components/WorktreePanel.test.ts @@ -0,0 +1,89 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from "vitest"; +import { mount, tick, unmount } from "svelte"; +import type { Ref, WorktreeInfo } from "../types"; +import { contextMenu } from "../contextMenu.svelte"; + +vi.mock("@tauri-apps/plugin-opener", () => ({ openPath: vi.fn() })); +vi.mock("../store.svelte", () => ({ + appState: { openRepo: vi.fn(), setActiveView: vi.fn() }, +})); +vi.mock("../dialogs.svelte", () => ({ + dialogs: { confirm: vi.fn().mockResolvedValue(false) }, +})); +vi.mock("../gitActions", () => ({ + gitActions: { removeWorktree: vi.fn() }, +})); + +import WorktreePanel from "./WorktreePanel.svelte"; + +function worktree(overrides: Partial = {}): WorktreeInfo { + return { + path: "/tmp/linked", + head: "a".repeat(40), + branch: "feature", + isMain: false, + isCurrent: false, + detached: false, + bare: false, + locked: false, + lockedReason: null, + prunable: false, + prunableReason: null, + status: { + head: { sha: "a".repeat(40), branch: "feature", detached: false }, + staged: 1, + unstaged: 0, + untracked: 0, + conflicted: 0, + operation: null, + }, + ...overrides, + }; +} + +afterEach(() => { + contextMenu.close(); + document.body.replaceChildren(); +}); + +describe("linked worktree panel", () => { + it("hides the primary checkout and opens an app-owned management menu", async () => { + const target = document.createElement("div"); + document.body.appendChild(target); + const refs: Ref[] = [ + { name: "feature", kind: "local", target_sha: "a", upstream: "origin/feature", ahead: 2, behind: 0 }, + { name: "origin/feature", kind: "remote", target_sha: "a", upstream: null, ahead: 0, behind: 0 }, + ]; + const panel = mount(WorktreePanel, { + target, + props: { + worktrees: [worktree({ path: "/tmp/main", branch: "main", isMain: true }), worktree()], + refs, + }, + }); + await tick(); + + const rows = target.querySelectorAll(".worktree"); + expect(rows).toHaveLength(1); + const event = new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + clientX: 30, + clientY: 40, + }); + rows[0].dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + expect(contextMenu.open).toBe(true); + expect(contextMenu.items.map((item) => item.label)).toEqual(expect.arrayContaining([ + "Open Worktree in Git It", + "View Local Changes", + "Open Worktree Folder", + "Remove Worktree…", + ])); + expect(contextMenu.items.some((item) => item.label === "↑2 to push")).toBe(true); + + await unmount(panel); + }); +}); diff --git a/src/lib/contextMenu.svelte.ts b/src/lib/contextMenu.svelte.ts index 6a7d99e..706522d 100644 --- a/src/lib/contextMenu.svelte.ts +++ b/src/lib/contextMenu.svelte.ts @@ -4,6 +4,7 @@ export type MenuItem = { action?: () => void; danger?: boolean; disabled?: boolean; + detail?: boolean; separator?: boolean; submenu?: MenuItem[]; }; diff --git a/src/lib/dialogs.svelte.ts b/src/lib/dialogs.svelte.ts index c684271..ffaede2 100644 --- a/src/lib/dialogs.svelte.ts +++ b/src/lib/dialogs.svelte.ts @@ -11,6 +11,14 @@ type BranchDeleteResult = { removeWorktree: boolean; }; +export type NewRepositoryResult = { + name: string; + initialBranch: string; + createRemote: boolean; + isPrivate: boolean; + description: string; +}; + type DialogState = | { kind: "none" } | { @@ -34,6 +42,7 @@ type DialogState = kind: "confirm"; title: string; message: string; + messageFormat: "text" | "markdown"; confirmLabel: string; danger: boolean; resolve: (v: boolean) => void; @@ -66,6 +75,17 @@ type DialogState = removeWorktree: boolean; resolve: (v: BranchDeleteResult) => void; } + | { + kind: "newRepository"; + title: string; + parent: string; + name: string; + initialBranch: string; + createRemote: boolean; + isPrivate: boolean; + description: string; + resolve: (v: NewRepositoryResult | null) => void; + } | { kind: "createRepo"; title: string; @@ -96,6 +116,7 @@ function makeDialogs() { else if (state.kind === "destructive") state.resolve({ confirmed: false, backup: false }); else if (state.kind === "credentials") state.resolve(null); else if (state.kind === "branchDelete") state.resolve({ confirmed: false, force: false, deleteRemote: false, removeWorktree: false }); + else if (state.kind === "newRepository") state.resolve(null); else if (state.kind === "createRepo") state.resolve({ confirmed: false, name: "", isPrivate: true, description: "" }); else if (state.kind === "createPr") state.resolve(null); else if (state.kind === "alert") state.resolve(); @@ -128,6 +149,7 @@ function makeDialogs() { confirm(opts: { title: string; message: string; + messageFormat?: "text" | "markdown"; confirmLabel?: string; danger?: boolean; }): Promise { @@ -137,6 +159,7 @@ function makeDialogs() { kind: "confirm", title: opts.title, message: opts.message, + messageFormat: opts.messageFormat ?? "text", confirmLabel: opts.confirmLabel ?? "Confirm", danger: !!opts.danger, resolve, @@ -249,6 +272,35 @@ function makeDialogs() { state = { kind: "none" }; } }, + // ── New local repository dialog ────────────────────────────────────────── + newRepository(opts: { parent: string }): Promise { + settlePending(); + return new Promise((resolve) => { + state = { + kind: "newRepository", + title: "Create new repository", + parent: opts.parent, + name: "", + initialBranch: "main", + createRemote: false, + isPrivate: true, + description: "", + resolve, + }; + }); + }, + setNewRepositoryName(v: string) { if (state.kind === "newRepository") state = { ...state, name: v }; }, + setNewRepositoryBranch(v: string) { if (state.kind === "newRepository") state = { ...state, initialBranch: v }; }, + setNewRepositoryRemote(v: boolean) { if (state.kind === "newRepository") state = { ...state, createRemote: v }; }, + setNewRepositoryPrivate(v: boolean) { if (state.kind === "newRepository") state = { ...state, isPrivate: v }; }, + setNewRepositoryDescription(v: string) { if (state.kind === "newRepository") state = { ...state, description: v }; }, + resolveNewRepository(accepted: boolean) { + if (state.kind === "newRepository") { + const { name, initialBranch, createRemote, isPrivate, description } = state; + state.resolve(accepted ? { name, initialBranch, createRemote, isPrivate, description } : null); + state = { kind: "none" }; + } + }, // ── Create-on-GitHub dialog ──────────────────────────────────────────────── createRepo(opts: { name: string }): Promise<{ confirmed: boolean; name: string; isPrivate: boolean; description: string }> { settlePending(); diff --git a/src/lib/gitActions.ts b/src/lib/gitActions.ts index 70daae0..bff1aad 100644 --- a/src/lib/gitActions.ts +++ b/src/lib/gitActions.ts @@ -714,6 +714,14 @@ export const gitActions = { run(`Delete branch ${name}`, () => api.deleteBranch(appState.repo, name, force, deleteRemote, remote, remoteBranch, worktreePath), ), + removeWorktree: async (path: string) => { + let removedPath: string | null = null; + const ok = await run("Remove worktree", async () => { + removedPath = await api.removeWorktree(appState.repo, path); + }); + if (ok && removedPath) appState.forgetRepo(removedPath); + return ok; + }, deleteRemoteBranch: (remote: string, branch: string) => run(`Delete remote branch ${remote}/${branch}`, () => api.deleteRemoteBranch(appState.repo, remote, branch), diff --git a/src/lib/repositoryFlows.test.ts b/src/lib/repositoryFlows.test.ts new file mode 100644 index 0000000..1f98290 --- /dev/null +++ b/src/lib/repositoryFlows.test.ts @@ -0,0 +1,165 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + pickRepoFolder: vi.fn(), + isGitRepo: vi.fn(), + initializeRepository: vi.fn(), + githubCurrentLogin: vi.fn(), + githubCreateRepo: vi.fn(), + newRepository: vi.fn(), + confirm: vi.fn(), + alert: vi.fn(), + openRepo: vi.fn(), + setBusyOp: vi.fn(), + appState: { status: "Ready", repo: "" }, +})); + +vi.mock("./api", () => ({ + pickRepoFolder: mocks.pickRepoFolder, + api: { + isGitRepo: mocks.isGitRepo, + initializeRepository: mocks.initializeRepository, + githubCurrentLogin: mocks.githubCurrentLogin, + githubCreateRepo: mocks.githubCreateRepo, + }, +})); +vi.mock("./dialogs.svelte", () => ({ + dialogs: { + newRepository: mocks.newRepository, + confirm: mocks.confirm, + alert: mocks.alert, + }, +})); +vi.mock("./store.svelte", () => ({ + appState: { + get status() { return mocks.appState.status; }, + set status(value: string) { mocks.appState.status = value; }, + get repo() { return mocks.appState.repo; }, + openRepo: mocks.openRepo, + setBusyOp: mocks.setBusyOp, + }, +})); + +async function loadFlows() { + vi.resetModules(); + return import("./repositoryFlows"); +} + +beforeEach(() => { + Object.defineProperty(window, "__TAURI_INTERNALS__", { value: {}, configurable: true }); + mocks.appState.status = "Ready"; + mocks.appState.repo = ""; + mocks.pickRepoFolder.mockReset().mockResolvedValue("/projects"); + mocks.isGitRepo.mockReset().mockResolvedValue(true); + mocks.initializeRepository.mockReset().mockResolvedValue({ + path: "/projects/app", + initialized: true, + existingEntries: 0, + }); + mocks.githubCurrentLogin.mockReset().mockResolvedValue("ashproto"); + mocks.githubCreateRepo.mockReset().mockResolvedValue("https://github.com/ashproto/app"); + mocks.newRepository.mockReset().mockResolvedValue({ + name: "app", + initialBranch: "main", + createRemote: false, + isPrivate: true, + description: "", + }); + mocks.confirm.mockReset().mockResolvedValue(true); + mocks.alert.mockReset().mockResolvedValue(undefined); + mocks.openRepo.mockReset(); + mocks.setBusyOp.mockReset(); +}); + +afterEach(() => { + Reflect.deleteProperty(window, "__TAURI_INTERNALS__"); +}); + +describe("repository creation flow", () => { + it("guards the entire flow against overlapping creation entry points", async () => { + let finishPicker!: (path: string | null) => void; + mocks.pickRepoFolder.mockImplementation( + () => new Promise((resolve) => { finishPicker = resolve; }), + ); + const { createRepositoryFlow } = await loadFlows(); + + const first = createRepositoryFlow(); + await createRepositoryFlow(); + + expect(mocks.pickRepoFolder).toHaveBeenCalledOnce(); + expect(mocks.appState.status).toBe("A repository is already being created."); + finishPicker(null); + await first; + }); + + it("creates and opens a local-only repository without touching GitHub", async () => { + const { createRepositoryFlow } = await loadFlows(); + + await createRepositoryFlow(); + + expect(mocks.initializeRepository).toHaveBeenCalledWith("/projects", "app", "main", false); + expect(mocks.openRepo).toHaveBeenCalledWith("/projects/app"); + expect(mocks.githubCurrentLogin).not.toHaveBeenCalled(); + expect(mocks.githubCreateRepo).not.toHaveBeenCalled(); + }); + + it("requires confirmation before initializing a non-empty folder", async () => { + mocks.initializeRepository + .mockResolvedValueOnce({ path: "/projects/app", initialized: false, existingEntries: 3 }) + .mockResolvedValueOnce({ path: "/projects/app", initialized: true, existingEntries: 3 }); + const { createRepositoryFlow } = await loadFlows(); + + await createRepositoryFlow(); + + expect(mocks.confirm).toHaveBeenCalledWith(expect.objectContaining({ + title: "Folder is not empty", + confirmLabel: "Initialize Here", + })); + expect(mocks.initializeRepository).toHaveBeenNthCalledWith(2, "/projects", "app", "main", true); + expect(mocks.openRepo).toHaveBeenCalledWith("/projects/app"); + }); + + it("preflights GitHub before local mutation and creates the remote against the canonical path", async () => { + mocks.newRepository.mockResolvedValue({ + name: "app", + initialBranch: "main", + createRemote: true, + isPrivate: false, + description: "A test repository", + }); + const { createRepositoryFlow } = await loadFlows(); + + await createRepositoryFlow(); + + expect(mocks.githubCurrentLogin.mock.invocationCallOrder[0]).toBeLessThan( + mocks.initializeRepository.mock.invocationCallOrder[0], + ); + expect(mocks.githubCreateRepo).toHaveBeenCalledWith( + "/projects/app", + "app", + false, + "A test repository", + ); + }); + + it("keeps and opens the local repository when GitHub setup fails", async () => { + mocks.newRepository.mockResolvedValue({ + name: "app", + initialBranch: "main", + createRemote: true, + isPrivate: true, + description: "", + }); + mocks.githubCreateRepo.mockRejectedValue({ kind: "Other", message: "name already exists" }); + const { createRepositoryFlow } = await loadFlows(); + + await createRepositoryFlow(); + + expect(mocks.openRepo).toHaveBeenCalledWith("/projects/app"); + expect(mocks.alert).toHaveBeenCalledWith(expect.objectContaining({ + title: "Local repository created", + message: expect.stringContaining("name already exists"), + })); + }); +}); diff --git a/src/lib/repositoryFlows.ts b/src/lib/repositoryFlows.ts new file mode 100644 index 0000000..b4f127d --- /dev/null +++ b/src/lib/repositoryFlows.ts @@ -0,0 +1,131 @@ +import { api, pickRepoFolder } from "./api"; +import { dialogs } from "./dialogs.svelte"; +import { appState } from "./store.svelte"; + +function isTauri(): boolean { + return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; +} + +let creating = false; + +function githubErrorMessage(error: unknown): string { + if (error && typeof error === "object" && "kind" in error) { + const value = error as { kind?: string; message?: string }; + if (value.kind === "NotInstalled") return "GitHub CLI is not installed. Install it with `brew install gh`."; + if (value.kind === "NotAuthed") return "GitHub CLI is not signed in. Run `gh auth login` and try again."; + if (value.kind === "RateLimited") return "GitHub's rate limit was reached. Try again later."; + if (value.kind === "Forbidden") return "GitHub refused this operation for the signed-in account."; + if (value.kind === "Other" && value.message) return value.message; + if (value.kind) return `GitHub setup failed: ${value.kind}.`; + } + return error instanceof Error ? error.message : String(error); +} + +export async function openRepositoryFlow(initial?: string): Promise { + if (!isTauri()) { + appState.status = "Opening a repo needs the desktop app."; + return; + } + const path = await pickRepoFolder(initial); + if (!path) return; + if (!(await api.isGitRepo(path))) { + appState.status = `${path} is not a git repo.`; + return; + } + appState.openRepo(path); +} + +export async function createRepositoryFlow(initial?: string): Promise { + if (!isTauri()) { + appState.status = "Creating a repo needs the desktop app."; + return; + } + if (creating) { + appState.status = "A repository is already being created."; + return; + } + creating = true; + try { + const parent = await pickRepoFolder(initial, "Choose where to create the repository"); + if (!parent) return; + const requested = await dialogs.newRepository({ parent }); + if (!requested) return; + + let createRemote = requested.createRemote; + // Authenticate before local mutation. If GitHub is unavailable, the user can + // still explicitly fall back to a local-only repository. + if (createRemote) { + try { + await api.githubCurrentLogin(); + } catch (error) { + const localOnly = await dialogs.confirm({ + title: "GitHub setup unavailable", + message: `${githubErrorMessage(error)}\n\nCreate the local repository without a remote?`, + confirmLabel: "Create Locally", + }); + if (!localOnly) return; + createRemote = false; + } + } + + appState.setBusyOp("Creating repository"); + appState.status = "Creating repository…"; + let initialized = await api.initializeRepository( + parent, + requested.name, + requested.initialBranch, + false, + ); + if (!initialized.initialized) { + appState.setBusyOp(null); + const proceed = await dialogs.confirm({ + title: "Folder is not empty", + message: `The destination already contains ${initialized.existingEntries} item${initialized.existingEntries === 1 ? "" : "s"}. Initialize Git there without changing those files?`, + confirmLabel: "Initialize Here", + }); + if (!proceed) return; + appState.setBusyOp("Creating repository"); + initialized = await api.initializeRepository( + parent, + requested.name, + requested.initialBranch, + true, + ); + } + if (!initialized.initialized) { + throw new Error("The destination could not be initialized after confirmation."); + } + + appState.openRepo(initialized.path); + if (!createRemote) { + appState.status = `Created local repository at ${initialized.path}.`; + return; + } + + appState.setBusyOp("Creating GitHub repository"); + appState.status = "Creating and connecting GitHub repository…"; + try { + await api.githubCreateRepo( + initialized.path, + requested.name, + requested.isPrivate, + requested.description, + ); + appState.status = "Created the local repository and connected its GitHub remote."; + } catch (error) { + const message = githubErrorMessage(error); + appState.status = `Local repository created; GitHub setup failed: ${message}`; + await dialogs.alert({ + title: "Local repository created", + message: `The local repository is ready at ${initialized.path}, but GitHub setup failed:\n\n${message}`, + }); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + appState.status = `Create repository failed: ${message}`; + await dialogs.alert({ title: "Create repository failed", message }); + } finally { + creating = false; + appState.setBusyOp(null); + } +} diff --git a/src/lib/store.svelte.ts b/src/lib/store.svelte.ts index d622228..185ff36 100644 --- a/src/lib/store.svelte.ts +++ b/src/lib/store.svelte.ts @@ -1409,28 +1409,33 @@ function makeState() { let autoUpdateCheckTouched = false; const ucHydrate = getStore(); - if (ucHydrate) { - ucHydrate + const updateChannelHydration: Promise = ucHydrate + ? ucHydrate .then((store) => store.get(UPDATE_CHANNEL_STORE_KEY)) .then((saved) => { if ((saved === "stable" || saved === "beta") && !updateChannelTouched) { updateChannel = saved; } }) - .catch((e) => console.warn("[gte] could not load updateChannel setting", e)); - } + .catch((e) => console.warn("[gte] could not load updateChannel setting", e)) + : Promise.resolve(); const aucHydrate = getStore(); - if (aucHydrate) { - aucHydrate + const autoUpdateCheckHydration: Promise = aucHydrate + ? aucHydrate .then((store) => store.get(AUTOUPDATE_CHECK_STORE_KEY)) .then((saved) => { if (saved !== null && saved !== undefined && !autoUpdateCheckTouched) { autoUpdateCheck = !!saved; } }) - .catch((e) => console.warn("[gte] could not load autoUpdateCheck setting", e)); - } + .catch((e) => console.warn("[gte] could not load autoUpdateCheck setting", e)) + : Promise.resolve(); + + const updateSettingsHydration = Promise.all([ + updateChannelHydration, + autoUpdateCheckHydration, + ]).then(() => undefined); function persistUpdateChannel() { const snapshot = updateChannel; @@ -2199,6 +2204,12 @@ function makeState() { autoUpdateCheck = v; persistAutoUpdateCheck(); }, + /** Startup checks must wait for both persisted updater preferences. Without + * this barrier, a beta install can briefly check the stable feed before the + * async Tauri store replaces the in-memory defaults. */ + waitForUpdateSettingsHydration(): Promise { + return updateSettingsHydration; + }, // Reads the *persisted* channel (not the in-memory default), so the updater's // first-run seed can tell "user has never chosen" from "user chose stable". // Returns null when no choice has been stored yet. @@ -2359,6 +2370,25 @@ function makeState() { persistStringList(OPENREPOS_STORE_KEY, OPENREPOS_KEY, openRepos); if (repo === path) this.repo = openRepos[idx] ?? openRepos[idx - 1] ?? openRepos[0] ?? ""; }, + + // Remove a path that no longer exists (for example, a deleted linked + // worktree) from both open tabs and recents so it cannot reopen as stale. + forgetRepo(path: string) { + if (!path) return; + const wasOpen = openRepos.includes(path); + const wasRecent = recentRepos.includes(path); + if (wasOpen) { + openRepos = openRepos.filter((entry) => entry !== path); + openReposTouched = true; + persistStringList(OPENREPOS_STORE_KEY, OPENREPOS_KEY, openRepos); + } + if (wasRecent) { + recentRepos = recentRepos.filter((entry) => entry !== path); + recentReposTouched = true; + persistStringList(RECENTREPOS_STORE_KEY, RECENTREPOS_KEY, recentRepos); + } + if (repo === path) this.repo = openRepos[0] ?? ""; + }, }; } diff --git a/src/lib/types.ts b/src/lib/types.ts index 0713604..45c3bd2 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -113,6 +113,12 @@ export type WorktreeInfo = { status: RepoStatus | null; }; +export type InitializeRepositoryResult = { + path: string; + initialized: boolean; + existingEntries: number; +}; + export type OpOutcome = { conflicted: boolean; files: string[]; diff --git a/src/lib/updater.svelte.ts b/src/lib/updater.svelte.ts index 610ad6d..50d9377 100644 --- a/src/lib/updater.svelte.ts +++ b/src/lib/updater.svelte.ts @@ -1,13 +1,10 @@ // In-app auto-updater flow. Desktop-only; a no-op in the browser/dev build. -// Mirrors Resume-Designer's state machine, re-surfaced through Git It's own -// surfaces: progress lands on the StatusBar (busyOp + status) and the -// download/restart prompts use dialogs.confirm. No toast library, and no -// pre-relaunch durability gate (Git It only persists settings/view-state -// fire-and-forget — there is no unsaved user document to protect). +// Progress lands on the StatusBar (busyOp + status), while download/restart +// decisions use the app's modal dialog system. import { appState } from "./store.svelte"; import { dialogs } from "./dialogs.svelte"; import { api } from "./api"; -import type { DownloadEvent } from "./types"; +import type { DownloadEvent, UpdateInfo } from "./types"; function isTauri(): boolean { return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; @@ -15,29 +12,55 @@ function isTauri(): boolean { const isDev = import.meta.env.DEV; let checking = false; -let lastBackgroundVersion: string | null = null; +let presenting = false; +let lastPromptedVersion: string | null = null; +let pendingAutomaticUpdate: UpdateInfo | null = null; +let pendingPromptTimer: ReturnType | null = null; let pollTimer: ReturnType | null = null; +let lastAutomaticCheckAt = 0; +let preparationPromise: Promise | null = null; + +const AUTOMATIC_CHECK_INTERVAL_MS = 30 * 60 * 1000; /** Manual "Check for Updates" (Settings button / macOS menu). */ export async function manualCheckForUpdates(): Promise { + if (!isTauri() || isDev) return; + await prepareUpdater(); await checkForUpdates("manual"); } /** Auto-check on launch — desktop + non-dev, gated on the autoUpdateCheck setting. */ export async function startupUpdateCheck(): Promise { if (!isTauri() || isDev) return; - // Seed the channel from the build type BEFORE the auto-check gate, so a fresh - // beta install lands on the beta channel even if auto-check is later turned off. - await seedChannelFromBuild(); + // Register polling before the first await. A slow settings-store read must not + // prevent this launch from ever installing its background timer. startBackgroundPolling(); + await prepareUpdater(); if (!appState.autoUpdateCheck) return; - await checkForUpdates("startup"); + await runAutomaticCheck("startup", true); +} + +/** Catch up after sleep/minimization, where WebKit may throttle interval timers. */ +export async function updateCheckOnActivate(): Promise { + if (!isTauri() || isDev) return; + await runPreparedAutomaticCheck("activation"); +} + +function prepareUpdater(): Promise { + if (!preparationPromise) { + preparationPromise = (async () => { + await appState.waitForUpdateSettingsHydration(); + // Seed only after persisted preferences are known. Every automatic entry + // point awaits this same promise, so focus/timer checks cannot race startup. + await seedChannelFromBuild(); + })(); + } + return preparationPromise; } // A beta (pre-release) build tracks the beta channel by default on first run so // it receives the rolling `next` pre-releases. Only acts when the user hasn't -// chosen a channel yet (no persisted value); never overrides a stored choice, -// and only ever flips stable → beta. +// chosen a channel yet (no persisted value); never overrides a stored choice. async function seedChannelFromBuild(): Promise { try { if ((await appState.getPersistedUpdateChannel()) !== null) return; @@ -51,53 +74,115 @@ async function seedChannelFromBuild(): Promise { } } -// Poll for updates every 30 minutes while the app is open. Notify-only, respects -// the auto-check setting live, and never runs in dev. function startBackgroundPolling(): void { if (pollTimer || isDev) return; - const THIRTY_MIN = 30 * 60 * 1000; pollTimer = setInterval(() => { - if (!appState.autoUpdateCheck) return; - void checkForUpdates("background"); - }, THIRTY_MIN); + void runPreparedAutomaticCheck("background"); + }, AUTOMATIC_CHECK_INTERVAL_MS); +} + +async function runPreparedAutomaticCheck( + source: "background" | "activation", +): Promise { + await prepareUpdater(); + if (!appState.autoUpdateCheck) return; + await tryPresentPendingAutomaticUpdate(); + await runAutomaticCheck(source); } -async function checkForUpdates(source: "manual" | "startup" | "background"): Promise { +async function runAutomaticCheck( + source: "startup" | "background" | "activation", + force = false, +): Promise { + if (!appState.autoUpdateCheck) return; + const now = Date.now(); + if (!force && now - lastAutomaticCheckAt < AUTOMATIC_CHECK_INTERVAL_MS) return; + lastAutomaticCheckAt = now; + await checkForUpdates(source); +} + +async function checkForUpdates( + source: "manual" | "startup" | "background" | "activation", +): Promise { if (!isTauri() || isDev) return; - if (checking) { - // A manual click while a check/download is already in flight: acknowledge it - // (the background/other flow owns the shared busyOp) instead of doing nothing. + if (checking || presenting) { if (source === "manual") appState.status = "Already checking for updates…"; return; } + checking = true; const manual = source === "manual"; + let available: UpdateInfo | null = null; if (manual) { appState.setBusyOp("Checking for updates"); appState.status = "Checking for updates…"; } try { - const update = await api.checkUpdateOnChannel(appState.updateChannel); - if (!update) { - if (manual) appState.status = "You are on the latest version."; - return; - } - // Background poll is notify-only: surface a non-modal status message and - // stop, so we never pop a blocking download dialog over the user's work. One - // message per new version (deduped). They install via Settings → Updates → - // Check for Updates (the manual flow). - if (source === "background") { - if (update.version === lastBackgroundVersion) return; - lastBackgroundVersion = update.version; - appState.status = `Update ${update.version} available — Settings → Updates to install.`; - return; - } + available = await api.checkUpdateOnChannel(appState.updateChannel); + if (!available && manual) appState.status = "You are on the latest version."; + } catch (err) { + reportUpdaterError(err); + } finally { + checking = false; + if (manual) appState.setBusyOp(null); + } + + if (!available) return; + if (manual) { + if (pendingAutomaticUpdate?.version === available.version) pendingAutomaticUpdate = null; + await presentUpdate(available); + } else { + queueAutomaticUpdate(available); + } +} + +function queueAutomaticUpdate(update: UpdateInfo): void { + if (update.version === lastPromptedVersion || update.version === pendingAutomaticUpdate?.version) { + return; + } + pendingAutomaticUpdate = update; + void tryPresentPendingAutomaticUpdate(); +} + +async function tryPresentPendingAutomaticUpdate(): Promise { + if (!appState.autoUpdateCheck) { + pendingAutomaticUpdate = null; + return; + } + if ( + !pendingAutomaticUpdate || + checking || + presenting || + dialogs.state.kind !== "none" + ) { + schedulePendingPromptRetry(); + return; + } + const update = pendingAutomaticUpdate; + pendingAutomaticUpdate = null; + await presentUpdate(update); +} + +function schedulePendingPromptRetry(): void { + if (!pendingAutomaticUpdate || pendingPromptTimer) return; + pendingPromptTimer = setTimeout(() => { + pendingPromptTimer = null; + void tryPresentPendingAutomaticUpdate(); + }, 1000); +} + +async function presentUpdate(update: UpdateInfo): Promise { + if (presenting) return; + presenting = true; + lastPromptedVersion = update.version; + try { const notes = (update.notes ?? "").trim(); const proceed = await dialogs.confirm({ title: `Update available — ${update.version}`, message: notes ? `Git It ${update.version} is available.\n\n${notes}` : `Git It ${update.version} is available. Download it now?`, + messageFormat: notes ? "markdown" : "text", confirmLabel: "Download", }); if (!proceed) { @@ -144,13 +229,18 @@ async function checkForUpdates(source: "manual" | "startup" | "background"): Pro clearTimeout(guard); } } catch (err) { - const raw = err instanceof Error ? err.message : String(err); - const sig = /signature|verify|verification|invalid/i.test(raw); - appState.status = sig - ? "Updater rejected the update (signature verification failed). The artifact may be unsigned or corrupted." - : `Updater error: ${raw}`; + reportUpdaterError(err); } finally { - checking = false; + presenting = false; appState.setBusyOp(null); + schedulePendingPromptRetry(); } } + +function reportUpdaterError(err: unknown): void { + const raw = err instanceof Error ? err.message : String(err); + const sig = /signature|verify|verification|invalid/i.test(raw); + appState.status = sig + ? "Updater rejected the update (signature verification failed). The artifact may be unsigned or corrupted." + : `Updater error: ${raw}`; +} diff --git a/src/lib/updater.test.ts b/src/lib/updater.test.ts new file mode 100644 index 0000000..74c2882 --- /dev/null +++ b/src/lib/updater.test.ts @@ -0,0 +1,145 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => { + const appState = { + updateChannel: "stable" as "stable" | "beta", + autoUpdateCheck: true, + status: "Ready", + waitForUpdateSettingsHydration: vi.fn<() => Promise>(), + getPersistedUpdateChannel: vi.fn<() => Promise<"stable" | "beta" | null>>(), + setUpdateChannel: vi.fn<(channel: "stable" | "beta") => void>(), + setBusyOp: vi.fn<(label: string | null) => void>(), + }; + const dialogState = { kind: "none" }; + return { + appState, + dialogState, + confirm: vi.fn(), + checkUpdateOnChannel: vi.fn(), + installPendingUpdate: vi.fn(), + getVersion: vi.fn(), + }; +}); + +vi.mock("./store.svelte", () => ({ appState: mocks.appState })); +vi.mock("./dialogs.svelte", () => ({ + dialogs: { state: mocks.dialogState, confirm: mocks.confirm }, +})); +vi.mock("./api", () => ({ + api: { + checkUpdateOnChannel: mocks.checkUpdateOnChannel, + installPendingUpdate: mocks.installPendingUpdate, + }, +})); +vi.mock("@tauri-apps/api/app", () => ({ getVersion: mocks.getVersion })); + +async function loadUpdater() { + vi.stubEnv("DEV", false); + vi.resetModules(); + return import("./updater.svelte"); +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-17T16:00:00-07:00")); + Object.defineProperty(window, "__TAURI_INTERNALS__", { value: {}, configurable: true }); + mocks.appState.updateChannel = "stable"; + mocks.appState.autoUpdateCheck = true; + mocks.appState.status = "Ready"; + mocks.dialogState.kind = "none"; + mocks.appState.waitForUpdateSettingsHydration.mockReset().mockResolvedValue(); + mocks.appState.getPersistedUpdateChannel.mockReset().mockResolvedValue("beta"); + mocks.appState.setUpdateChannel.mockReset().mockImplementation((channel) => { + mocks.appState.updateChannel = channel; + }); + mocks.appState.setBusyOp.mockReset(); + mocks.confirm.mockReset().mockResolvedValue(false); + mocks.checkUpdateOnChannel.mockReset().mockResolvedValue(null); + mocks.installPendingUpdate.mockReset().mockResolvedValue(undefined); + mocks.getVersion.mockReset().mockResolvedValue("0.3.0-next.20"); +}); + +afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + vi.unstubAllEnvs(); + Reflect.deleteProperty(window, "__TAURI_INTERNALS__"); +}); + +describe("automatic updater lifecycle", () => { + it("registers polling before hydration and checks the hydrated beta channel", async () => { + let finishHydration!: () => void; + mocks.appState.waitForUpdateSettingsHydration.mockImplementation( + () => new Promise((resolve) => { + finishHydration = () => { + mocks.appState.updateChannel = "beta"; + resolve(); + }; + }), + ); + const updater = await loadUpdater(); + + const startup = updater.startupUpdateCheck(); + expect(vi.getTimerCount()).toBe(1); + expect(mocks.checkUpdateOnChannel).not.toHaveBeenCalled(); + // A focus event during hydration must join the same readiness barrier, not + // run an early check against the in-memory stable default. + const activation = updater.updateCheckOnActivate(); + finishHydration(); + await Promise.all([startup, activation]); + + expect(mocks.checkUpdateOnChannel).toHaveBeenCalledOnce(); + expect(mocks.checkUpdateOnChannel).toHaveBeenCalledWith("beta"); + }); + + it("proactively prompts once per version and opts release notes into markdown", async () => { + mocks.checkUpdateOnChannel.mockResolvedValue({ + version: "0.3.0-next.21", + currentVersion: "0.3.0-next.20", + notes: "## Fixes\n\n- **UI** — Render notes", + }); + const updater = await loadUpdater(); + + await updater.startupUpdateCheck(); + await vi.runAllTicks(); + + expect(mocks.confirm).toHaveBeenCalledOnce(); + expect(mocks.confirm).toHaveBeenCalledWith(expect.objectContaining({ + title: "Update available — 0.3.0-next.21", + messageFormat: "markdown", + confirmLabel: "Download", + })); + + await vi.advanceTimersByTimeAsync(30 * 60 * 1000); + expect(mocks.checkUpdateOnChannel).toHaveBeenCalledTimes(2); + expect(mocks.confirm).toHaveBeenCalledOnce(); + }); + + it("defers an automatic prompt until another dialog has closed", async () => { + mocks.dialogState.kind = "prompt"; + mocks.checkUpdateOnChannel.mockResolvedValue({ + version: "0.3.0-next.21", + currentVersion: "0.3.0-next.20", + notes: null, + }); + const updater = await loadUpdater(); + + await updater.startupUpdateCheck(); + expect(mocks.confirm).not.toHaveBeenCalled(); + + mocks.dialogState.kind = "none"; + await vi.advanceTimersByTimeAsync(1000); + expect(mocks.confirm).toHaveBeenCalledOnce(); + }); + + it("uses activation as a catch-up check after a throttled timer window", async () => { + const updater = await loadUpdater(); + await updater.startupUpdateCheck(); + expect(mocks.checkUpdateOnChannel).toHaveBeenCalledOnce(); + + vi.setSystemTime(new Date("2026-07-17T16:31:00-07:00")); + await updater.updateCheckOnActivate(); + expect(mocks.checkUpdateOnChannel).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/lib/worktrees.test.ts b/src/lib/worktrees.test.ts index 70b75cc..ef9fc4a 100644 --- a/src/lib/worktrees.test.ts +++ b/src/lib/worktrees.test.ts @@ -1,10 +1,14 @@ import { describe, expect, it } from "vitest"; -import type { WorktreeInfo } from "./types"; +import type { Ref, WorktreeInfo } from "./types"; import { + linkedWorktreeForBranch, + linkedWorktrees, worktreeForBranch, worktreeIsDirty, worktreeRemovalBlocker, + worktreeStatusDetail, worktreeStatusLabel, + worktreeTrackingDetail, } from "./worktrees"; function worktree(overrides: Partial = {}): WorktreeInfo { @@ -39,6 +43,15 @@ describe("worktree helpers", () => { expect(worktreeForBranch([feature], "main")).toBeUndefined(); }); + it("keeps the primary checkout for safety lookups but excludes it from linked UI", () => { + const primary = worktree({ branch: "main", isMain: true, isCurrent: true }); + const feature = worktree(); + expect(linkedWorktrees([primary, feature])).toEqual([feature]); + expect(worktreeForBranch([primary, feature], "main")).toBe(primary); + expect(linkedWorktreeForBranch([primary, feature], "main")).toBeUndefined(); + expect(linkedWorktreeForBranch([primary, feature], "feature")).toBe(feature); + }); + it("treats file changes, conflicts, and in-progress operations as dirty", () => { expect(worktreeIsDirty(worktree())).toBe(false); expect(worktreeIsDirty(worktree({ status: { ...worktree().status!, untracked: 1 } }))).toBe(true); @@ -49,7 +62,7 @@ describe("worktree helpers", () => { it("allows only a verified clean linked worktree to be removed", () => { expect(worktreeRemovalBlocker(worktree())).toBeNull(); expect(worktreeRemovalBlocker(worktree({ isCurrent: true }))).toContain("currently open"); - expect(worktreeRemovalBlocker(worktree({ isMain: true }))).toContain("main worktree"); + expect(worktreeRemovalBlocker(worktree({ isMain: true }))).toContain("primary checkout"); expect(worktreeRemovalBlocker(worktree({ locked: true, lockedReason: "in use" }))).toContain("in use"); expect(worktreeRemovalBlocker(worktree({ prunable: true }))).toContain("stale or missing"); expect(worktreeRemovalBlocker(worktree({ status: null }))).toContain("could not verify"); @@ -65,7 +78,23 @@ describe("worktree helpers", () => { expect(worktreeStatusLabel(worktree({ isCurrent: true, locked: true }))).toBe("Current"); expect(worktreeStatusLabel(worktree({ locked: true }))).toBe("Locked"); expect(worktreeStatusLabel(worktree({ prunable: true }))).toBe("Missing"); + expect(worktreeStatusLabel(worktree({ status: { ...worktree().status!, operation: "rebase" } }))).toBe("Rebase"); expect(worktreeStatusLabel(worktree({ status: { ...worktree().status!, staged: 1 } }))).toBe("Modified"); expect(worktreeStatusLabel(worktree())).toBeNull(); }); + + it("reports local and upstream state without conflating dimensions", () => { + const changed = worktree({ + status: { ...worktree().status!, staged: 2, unstaged: 1, untracked: 3 }, + }); + expect(worktreeStatusDetail(changed)).toBe("2 staged · 1 unstaged · 3 untracked"); + + const refs: Ref[] = [ + { name: "feature", kind: "local", target_sha: "a", upstream: "origin/feature", ahead: 2, behind: 1 }, + { name: "origin/feature", kind: "remote", target_sha: "b", upstream: null, ahead: 0, behind: 0 }, + ]; + expect(worktreeTrackingDetail(changed, refs)).toBe("↑2 to push · ↓1 to pull"); + expect(worktreeTrackingDetail(changed, refs.slice(0, 1))).toBe("Upstream missing — origin/feature"); + expect(worktreeTrackingDetail(changed, [{ ...refs[0], upstream: null }])).toBe("No upstream branch"); + }); }); diff --git a/src/lib/worktrees.ts b/src/lib/worktrees.ts index fa18fc6..f5212b4 100644 --- a/src/lib/worktrees.ts +++ b/src/lib/worktrees.ts @@ -1,4 +1,8 @@ -import type { WorktreeInfo } from "./types"; +import type { Ref, WorktreeInfo } from "./types"; + +export function linkedWorktrees(worktrees: readonly WorktreeInfo[]): WorktreeInfo[] { + return worktrees.filter((worktree) => !worktree.isMain); +} export function worktreeForBranch( worktrees: readonly WorktreeInfo[], @@ -7,6 +11,13 @@ export function worktreeForBranch( return worktrees.find((worktree) => worktree.branch === branch); } +export function linkedWorktreeForBranch( + worktrees: readonly WorktreeInfo[], + branch: string, +): WorktreeInfo | undefined { + return worktrees.find((worktree) => !worktree.isMain && worktree.branch === branch); +} + export function worktreeIsDirty(worktree: WorktreeInfo): boolean { const status = worktree.status; return !!status && ( @@ -22,7 +33,7 @@ export function worktreeIsDirty(worktree: WorktreeInfo): boolean { * every check immediately before executing; this is the explanatory UI gate. */ export function worktreeRemovalBlocker(worktree: WorktreeInfo): string | null { if (worktree.isMain) { - return "Git's main worktree cannot be removed. Check out a different branch there before deleting this branch."; + return "The repository's primary checkout cannot be removed as a linked worktree."; } if (worktree.isCurrent) { return "This is the worktree currently open in Git It. Open the repository from another worktree before removing it."; @@ -37,8 +48,11 @@ export function worktreeRemovalBlocker(worktree: WorktreeInfo): string | null { ? `This worktree is stale or missing: ${worktree.prunableReason}` : "This worktree is stale or missing. Prune its Git metadata before deleting the branch."; } - if (worktree.bare || worktree.detached || worktree.branch === null) { - return "This worktree no longer has the branch checked out. Refresh the repository and try again."; + if (worktree.detached) { + return "This detached worktree cannot be removed here because its commits may not be referenced by a branch."; + } + if (worktree.bare || worktree.branch === null) { + return "This worktree does not have a branch checked out. Refresh the repository and try again."; } if (worktree.status === null) { return "Git It could not verify that this worktree is clean, so it will not remove it."; @@ -54,8 +68,47 @@ export function worktreeStatusLabel(worktree: WorktreeInfo): string | null { if (worktree.locked) return "Locked"; if (worktree.prunable) return "Missing"; if (worktree.status === null && !worktree.bare) return "Unavailable"; + if (worktree.status?.operation) { + const operation = worktree.status.operation; + return operation.charAt(0).toUpperCase() + operation.slice(1); + } if (worktreeIsDirty(worktree)) return "Modified"; if (worktree.detached) return "Detached"; if (worktree.bare) return "Bare"; return null; } + +export function worktreeStatusDetail(worktree: WorktreeInfo): string { + if (worktree.locked) return worktree.lockedReason + ? `Locked — ${worktree.lockedReason}` + : "Locked"; + if (worktree.prunable) return worktree.prunableReason + ? `Missing — ${worktree.prunableReason}` + : "Missing from disk"; + if (!worktree.status) return "Local state unavailable"; + const parts: string[] = []; + if (worktree.status.operation) parts.push(`${worktree.status.operation} in progress`); + if (worktree.status.staged) parts.push(`${worktree.status.staged} staged`); + if (worktree.status.unstaged) parts.push(`${worktree.status.unstaged} unstaged`); + if (worktree.status.untracked) parts.push(`${worktree.status.untracked} untracked`); + if (worktree.status.conflicted) parts.push(`${worktree.status.conflicted} conflicted`); + return parts.length ? parts.join(" · ") : "Working tree clean"; +} + +export function worktreeTrackingDetail( + worktree: WorktreeInfo, + refs: readonly Ref[], +): string | null { + if (!worktree.branch) return null; + const local = refs.find((ref) => ref.kind === "local" && ref.name === worktree.branch); + if (!local) return null; + if (!local.upstream) return "No upstream branch"; + const upstreamExists = refs.some( + (ref) => ref.kind === "remote" && ref.name === local.upstream, + ); + if (!upstreamExists) return `Upstream missing — ${local.upstream}`; + const counts: string[] = []; + if (local.ahead) counts.push(`↑${local.ahead} to push`); + if (local.behind) counts.push(`↓${local.behind} to pull`); + return counts.length ? counts.join(" · ") : `Up to date with ${local.upstream}`; +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 76d1e26..e57db74 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -9,7 +9,11 @@ import PrereqBanner from "$lib/components/PrereqBanner.svelte"; import SettingsPanel from "$lib/components/SettingsPanel.svelte"; import { settingsPanel } from "$lib/settingsPanel.svelte"; - import { startupUpdateCheck, manualCheckForUpdates } from "$lib/updater.svelte"; + import { + startupUpdateCheck, + manualCheckForUpdates, + updateCheckOnActivate, + } from "$lib/updater.svelte"; import ManageRepoModal from "$lib/components/ManageRepoModal.svelte"; import { manageRepo } from "$lib/manageRepo.svelte"; import BranchColorDialog from "$lib/components/BranchColorDialog.svelte"; @@ -37,7 +41,8 @@ import { graphView } from "$lib/graphView.svelte"; import { dialogs } from "$lib/dialogs.svelte"; import { anyOverlayOpen } from "$lib/overlays"; - import { pickRepoFolder, api } from "$lib/api"; + import { api } from "$lib/api"; + import { createRepositoryFlow, openRepositoryFlow } from "$lib/repositoryFlows"; import { onWindowDragMouseDown } from "$lib/tauriDrag"; import { onMount, untrack, tick } from "svelte"; import { slide } from "svelte/transition"; @@ -48,7 +53,9 @@ import { SAMPLE_GRAPH } from "$lib/graph/sample"; const currentBranch = $derived( - appState.refsByKind.local.find((r) => r.isHead)?.name ?? null, + appState.refsByKind.local.find((r) => r.isHead)?.name ?? + appState.repoStatus?.head.branch ?? + null, ); // The Fetch button swaps to a spinner + "Fetching…" while its run() op is in // flight (run() labels the op "Fetch"). Feedback lands where the user clicked. @@ -142,18 +149,7 @@ // ── Empty-state open flow ───────────────────────────────────────────────────── async function openRepoFlow() { - const inTauri = typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; - if (!inTauri) { - appState.status = "Opening a repo needs the desktop app."; - return; - } - const p = await pickRepoFolder(appState.repo || undefined); - if (!p) return; - if (!(await api.isGitRepo(p))) { - appState.status = `${p} is not a git repo.`; - return; - } - appState.openRepo(p); + await openRepositoryFlow(appState.repo || undefined); } // Collapse state of the two timeline panels, lifted here so the layout can give the @@ -282,6 +278,8 @@ import("@tauri-apps/api/event").then(async ({ listen }) => { unlistenMenu.push(await listen("menu:check-updates", () => manualCheckForUpdates())); unlistenMenu.push(await listen("menu:open-settings", () => settingsPanel.openPanel())); + unlistenMenu.push(await listen("menu:new-repository", () => createRepositoryFlow())); + unlistenMenu.push(await listen("menu:open-repository", () => openRepoFlow())); }); } // Reload the graph + working copy + status when the window regains focus / @@ -289,13 +287,19 @@ // Complements the filesystem watcher (which handles changes while the window // is already active). refreshActiveRepo no-ops outside Tauri / with no repo. const onActivate = () => { - if (document.visibilityState === "visible") refreshActiveRepo(); + if (document.visibilityState === "visible") { + refreshActiveRepo(); + updateCheckOnActivate(); + } }; + const suppressNativeContextMenu = (event: MouseEvent) => event.preventDefault(); window.addEventListener("focus", onActivate); document.addEventListener("visibilitychange", onActivate); + window.addEventListener("contextmenu", suppressNativeContextMenu); return () => { window.removeEventListener("focus", onActivate); document.removeEventListener("visibilitychange", onActivate); + window.removeEventListener("contextmenu", suppressNativeContextMenu); unlistenMenu.forEach((u) => u()); }; }); @@ -544,8 +548,11 @@ {#if isEmpty}
-

Open a repository to get started

- +

Open or create a repository to get started

+
+ + +
{:else}
@@ -1096,6 +1103,10 @@ font-size: 15px; color: var(--text-muted); } + .empty-actions { + display: flex; + gap: 9px; + } .open-btn { padding: 8px 20px; border-radius: 7px; From 756e26947b5b4e546fbc7ac2a2d6bb2c9dcae2aa Mon Sep 17 00:00:00 2001 From: Ash Shah <494shah@gmail.com> Date: Fri, 17 Jul 2026 18:22:52 -0700 Subject: [PATCH 19/20] fix: clear removed worktree tabs after branch deletion --- src/lib/gitActions.partialFailure.test.ts | 73 +++++++++++++++++++++++ src/lib/gitActions.ts | 31 ++++++++-- 2 files changed, 99 insertions(+), 5 deletions(-) diff --git a/src/lib/gitActions.partialFailure.test.ts b/src/lib/gitActions.partialFailure.test.ts index c070c9b..2a7660c 100644 --- a/src/lib/gitActions.partialFailure.test.ts +++ b/src/lib/gitActions.partialFailure.test.ts @@ -76,6 +76,79 @@ afterEach(() => { }); describe("partial branch-delete refresh", () => { + it("forgets a removed worktree path after a successful branch deletion", async () => { + mockRefreshApis(graphWithoutFeatureDecoration()); + vi.mocked(api.listWorktrees).mockRejectedValue(new Error("worktree refresh failed")); + vi.spyOn(api, "deleteBranch").mockResolvedValue(undefined); + const forgetRepo = vi.spyOn(appState, "forgetRepo"); + + const ok = await gitActions.deleteBranch( + "feature/graph-view", + false, + false, + undefined, + undefined, + "/tmp/linked-feature", + ); + + expect(ok).toBe(true); + expect(forgetRepo).toHaveBeenCalledWith("/tmp/linked-feature"); + }); + + it("forgets a removed worktree path when a later remote deletion fails", async () => { + mockRefreshApis(graphWithoutFeatureDecoration()); + vi.spyOn(api, "deleteBranch").mockRejectedValue( + new Error("Deleted local branch, but remote delete failed"), + ); + const forgetRepo = vi.spyOn(appState, "forgetRepo"); + + const ok = await gitActions.deleteBranch( + "feature/graph-view", + false, + true, + "origin", + "feature/graph-view", + "/tmp/linked-feature", + ); + + expect(ok).toBe(false); + expect(forgetRepo).toHaveBeenCalledWith("/tmp/linked-feature"); + }); + + it("keeps a worktree path when branch deletion fails before removal", async () => { + mockRefreshApis(graphWithoutFeatureDecoration()); + vi.mocked(api.listWorktrees).mockResolvedValue([ + { + path: "/tmp/linked-feature", + head: SAMPLE_GRAPH[2].sha, + branch: "feature/graph-view", + detached: false, + bare: false, + locked: false, + lockedReason: null, + prunable: false, + prunableReason: null, + isCurrent: false, + isMain: false, + status: repoStatus, + }, + ]); + vi.spyOn(api, "deleteBranch").mockRejectedValue(new Error("worktree is dirty")); + const forgetRepo = vi.spyOn(appState, "forgetRepo"); + + const ok = await gitActions.deleteBranch( + "feature/graph-view", + false, + false, + undefined, + undefined, + "/tmp/linked-feature", + ); + + expect(ok).toBe(false); + expect(forgetRepo).not.toHaveBeenCalled(); + }); + it("removes stale graph decorations without discarding queued edits", async () => { const refreshedGraph = graphWithoutFeatureDecoration(); mockRefreshApis(refreshedGraph); diff --git a/src/lib/gitActions.ts b/src/lib/gitActions.ts index bff1aad..5440ea3 100644 --- a/src/lib/gitActions.ts +++ b/src/lib/gitActions.ts @@ -703,17 +703,38 @@ export const gitActions = { run(`Create branch ${name}`, () => api.createBranch(appState.repo, name, startPoint)), renameBranch: (oldName: string, newName: string) => run(`Rename ${oldName} → ${newName}`, () => api.renameBranch(appState.repo, oldName, newName)), - deleteBranch: ( + deleteBranch: async ( name: string, force: boolean, deleteRemote = false, remote?: string, remoteBranch?: string, worktreePath?: string, - ) => - run(`Delete branch ${name}`, () => - api.deleteBranch(appState.repo, name, force, deleteRemote, remote, remoteBranch, worktreePath), - ), + ) => { + const repo = appState.repo; + const ok = await run(`Delete branch ${name}`, () => + api.deleteBranch(repo, name, force, deleteRemote, remote, remoteBranch, worktreePath), + ); + if (worktreePath && repo && isTauri()) { + if (ok) { + // Backend success proves the requested linked worktree was removed. + appState.forgetRepo(worktreePath); + } else { + try { + // The backend removes the linked worktree before a requested remote + // delete. Reconcile after an error so that partial success clears a + // stale tab, without forgetting a worktree whose removal was refused. + const worktrees = await api.listWorktrees(repo); + if (!worktrees.some((worktree) => worktree.path === worktreePath)) { + appState.forgetRepo(worktreePath); + } + } catch (error) { + console.warn("[gte] could not verify worktree removal after branch deletion", error); + } + } + } + return ok; + }, removeWorktree: async (path: string) => { let removedPath: string | null = null; const ok = await run("Remove worktree", async () => { From 0e660e0962e716111aa3e0ba663465051f76ec4e Mon Sep 17 00:00:00 2001 From: Ash Shah <494shah@gmail.com> Date: Fri, 17 Jul 2026 18:34:26 -0700 Subject: [PATCH 20/20] fix: refresh remotes after GitHub repository creation --- src/lib/repositoryFlows.test.ts | 9 ++++++++- src/lib/repositoryFlows.ts | 5 +++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/lib/repositoryFlows.test.ts b/src/lib/repositoryFlows.test.ts index 1f98290..206c858 100644 --- a/src/lib/repositoryFlows.test.ts +++ b/src/lib/repositoryFlows.test.ts @@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => ({ newRepository: vi.fn(), confirm: vi.fn(), alert: vi.fn(), + refreshRefs: vi.fn(), openRepo: vi.fn(), setBusyOp: vi.fn(), appState: { status: "Ready", repo: "" }, @@ -31,6 +32,7 @@ vi.mock("./dialogs.svelte", () => ({ alert: mocks.alert, }, })); +vi.mock("./gitActions", () => ({ refreshRefs: mocks.refreshRefs })); vi.mock("./store.svelte", () => ({ appState: { get status() { return mocks.appState.status; }, @@ -68,7 +70,10 @@ beforeEach(() => { }); mocks.confirm.mockReset().mockResolvedValue(true); mocks.alert.mockReset().mockResolvedValue(undefined); - mocks.openRepo.mockReset(); + mocks.refreshRefs.mockReset().mockResolvedValue(undefined); + mocks.openRepo.mockReset().mockImplementation((path: string) => { + mocks.appState.repo = path; + }); mocks.setBusyOp.mockReset(); }); @@ -141,6 +146,7 @@ describe("repository creation flow", () => { false, "A test repository", ); + expect(mocks.refreshRefs).toHaveBeenCalledOnce(); }); it("keeps and opens the local repository when GitHub setup fails", async () => { @@ -161,5 +167,6 @@ describe("repository creation flow", () => { title: "Local repository created", message: expect.stringContaining("name already exists"), })); + expect(mocks.refreshRefs).not.toHaveBeenCalled(); }); }); diff --git a/src/lib/repositoryFlows.ts b/src/lib/repositoryFlows.ts index b4f127d..8e9c990 100644 --- a/src/lib/repositoryFlows.ts +++ b/src/lib/repositoryFlows.ts @@ -1,5 +1,6 @@ import { api, pickRepoFolder } from "./api"; import { dialogs } from "./dialogs.svelte"; +import { refreshRefs } from "./gitActions"; import { appState } from "./store.svelte"; function isTauri(): boolean { @@ -111,6 +112,10 @@ export async function createRepositoryFlow(initial?: string): Promise { requested.isPrivate, requested.description, ); + // openRepo starts its initial metadata load before the network request + // creates origin. Refresh after success so remote-dependent controls do + // not remain disabled with the pre-creation empty snapshot. + if (appState.repo === initialized.path) await refreshRefs(); appState.status = "Created the local repository and connected its GitHub remote."; } catch (error) { const message = githubErrorMessage(error);