diff --git a/README.md b/README.md index e6cdc9f..1c42534 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,8 @@ [![Downloads](https://img.shields.io/github/downloads/ashproto/git-it/total)](https://github.com/ashproto/git-it/releases) ![Platform](https://img.shields.io/badge/platform-macOS%2012.3%2B-000000?logo=apple&logoColor=white) ![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202%20%2B%20SvelteKit-24C8DB?logo=tauri&logoColor=white) +[![Website](https://img.shields.io/badge/website-git--it.app-F2542D)](https://git-it.app) +[![License: CC BY-NC-SA 4.0](https://img.shields.io/badge/license-CC%20BY--NC--SA%204.0-blue)](LICENSE)

Git It — the commit graph, refs sidebar, and toolbar 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/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 47df93d..5d89f9c 100644 --- a/crates/git-core/src/ops.rs +++ b/crates/git-core/src/ops.rs @@ -1,6 +1,811 @@ use crate::git_ops; -use std::path::Path; -use std::process::Command; +use crate::graph; +use crate::types::WorktreeInfo; +use std::fs; +use std::fs::OpenOptions; +use std::io::{BufRead, BufReader, ErrorKind, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; +use std::str; + +#[derive(Debug, Default)] +struct ParsedWorktree { + seen: bool, + path: String, + head: Option, + branch: Option, + detached: bool, + bare: bool, + locked: bool, + locked_reason: Option, + prunable: bool, + prunable_reason: Option, +} + +fn finish_worktree( + current: &mut ParsedWorktree, + out: &mut Vec, +) -> Result<(), String> { + if !current.seen { + return Ok(()); + } + if current.path.is_empty() { + return Err("git worktree output contained a record without a path".to_string()); + } + out.push(std::mem::take(current)); + Ok(()) +} + +/// Parse the stable NUL-delimited worktree porcelain format. Splitting each +/// field at only its first space preserves spaces and newlines inside paths and +/// lock/prune reasons. Invalid UTF-8 is rejected instead of being made lossy: +/// this path may later identify a destructive removal target. +fn parse_worktree_porcelain(raw: &[u8]) -> Result, String> { + let mut out = Vec::new(); + let mut current = ParsedWorktree::default(); + + for field in raw.split(|b| *b == 0) { + if field.is_empty() { + finish_worktree(&mut current, &mut out)?; + continue; + } + let split = field.iter().position(|b| *b == b' '); + let (key_raw, value_raw) = match split { + Some(i) => (&field[..i], &field[i + 1..]), + None => (field, &[][..]), + }; + let key = str::from_utf8(key_raw) + .map_err(|_| "git worktree output contained an invalid UTF-8 field name".to_string())?; + let value = str::from_utf8(value_raw) + .map_err(|_| format!("git worktree {key} contained an invalid UTF-8 value"))?; + + // Be tolerant of a missing blank separator while still keeping records + // isolated if a future Git version starts a new `worktree` field. + if key == "worktree" && current.seen { + finish_worktree(&mut current, &mut out)?; + } + current.seen = true; + match key { + "worktree" => current.path = value.to_string(), + "HEAD" => current.head = Some(value.to_string()), + "branch" => current.branch = value.strip_prefix("refs/heads/").map(str::to_string), + "detached" => current.detached = true, + "bare" => current.bare = true, + "locked" => { + current.locked = true; + current.locked_reason = (!value.is_empty()).then(|| value.to_string()); + } + "prunable" => { + current.prunable = true; + current.prunable_reason = (!value.is_empty()).then(|| value.to_string()); + } + _ => {} // forward-compatible: ignore fields added by future Git versions + } + } + finish_worktree(&mut current, &mut out)?; + Ok(out) +} + +fn same_existing_path(a: &Path, b: &Path) -> bool { + match (fs::canonicalize(a), fs::canonicalize(b)) { + (Ok(a), Ok(b)) => a == b, + _ => a == b, + } +} + +struct WorktreeHeadLock { + path: PathBuf, + // Keeping the descriptor open makes ownership of the standard Git lock + // unambiguous until removal finishes. + _file: fs::File, +} + +struct BranchDeleteSafety { + branch_oid: 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 +/// worktree folder is removed. The expected old OID makes a concurrent ref +/// update fail before any filesystem deletion, and dropping an uncommitted +/// transaction aborts it so the branch remains intact. +struct PreparedBranchDelete { + child: Child, + stdin: Option, + stdout: BufReader, + finished: bool, +} + +impl PreparedBranchDelete { + fn prepare( + repo: &Path, + refname: &str, + expected_oid: &str, + verifications: &[(String, Option)], + ) -> Result { + let mut child = Command::new("git") + .current_dir(repo) + .env("LC_ALL", "C") + .args(["update-ref", "--stdin"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("Failed to start the branch delete transaction: {e}"))?; + let stdin = child + .stdin + .take() + .ok_or_else(|| "Git did not open the branch transaction input.".to_string())?; + let stdout = child + .stdout + .take() + .ok_or_else(|| "Git did not open the branch transaction output.".to_string())?; + let mut transaction = Self { + child, + stdin: Some(stdin), + stdout: BufReader::new(stdout), + finished: false, + }; + transaction.send("start")?; + transaction.expect_response("start: ok")?; + transaction.send(&format!("delete {refname} {expected_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")?; + Ok(transaction) + } + + fn send(&mut self, command: &str) -> Result<(), String> { + let stdin = self + .stdin + .as_mut() + .ok_or_else(|| "The branch transaction is already closed.".to_string())?; + writeln!(stdin, "{command}") + .and_then(|_| stdin.flush()) + .map_err(|e| format!("Could not communicate with the branch transaction: {e}")) + } + + fn expect_response(&mut self, expected: &str) -> Result<(), String> { + let mut response = String::new(); + let read = self + .stdout + .read_line(&mut response) + .map_err(|e| format!("Could not read the branch transaction response: {e}"))?; + if read == 0 { + return Err("Git ended the branch transaction unexpectedly.".to_string()); + } + if response.trim_end() != expected { + return Err(format!( + "Git returned an unexpected branch transaction response: {}", + response.trim_end() + )); + } + Ok(()) + } + + fn commit(mut self) -> Result<(), String> { + self.send("commit")?; + self.expect_response("commit: ok")?; + self.stdin.take(); + let status = self + .child + .wait() + .map_err(|e| format!("Could not finish the branch transaction: {e}"))?; + self.finished = true; + if status.success() { + Ok(()) + } else { + Err("Git could not commit the prepared branch deletion.".to_string()) + } + } +} + +impl Drop for PreparedBranchDelete { + fn drop(&mut self) { + if self.finished { + return; + } + let _ = self.send("abort"); + let _ = self.expect_response("abort: ok"); + self.stdin.take(); + let _ = self.child.wait(); + } +} + +impl Drop for WorktreeHeadLock { + fn drop(&mut self) { + // A successful `git worktree remove` deletes the administrative + // directory (and therefore this lock) for us. On any earlier failure, + // release only the lock file we created. + if self.path.exists() { + let _ = fs::remove_file(&self.path); + } + } +} + +fn git_path_output(repo: &Path, args: &[&str], description: &str) -> Result { + let output = Command::new("git") + .current_dir(repo) + .args(args) + .output() + .map_err(|e| format!("Failed to resolve {description}: {e}"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(if stderr.trim().is_empty() { + format!("Could not resolve {description}.") + } else { + stderr.into_owned() + }); + } + let stdout = str::from_utf8(&output.stdout) + .map_err(|_| format!("Git returned an invalid UTF-8 {description}."))?; + // Remove exactly Git's record terminator, preserving any newline that is + // actually part of the path. + let value = stdout.strip_suffix('\n').unwrap_or(stdout); + let value = value.strip_suffix('\r').unwrap_or(value); + if value.is_empty() { + return Err(format!("Git returned an empty {description}.")); + } + let path = PathBuf::from(value); + if !path.is_absolute() { + return Err(format!("Git returned a non-absolute {description}.")); + } + fs::canonicalize(&path).map_err(|e| format!("Could not verify {description}: {e}")) +} + +fn acquire_worktree_head_lock(repo: &Path, worktree: &Path) -> Result { + let repo_common = git_path_output( + repo, + &["rev-parse", "--path-format=absolute", "--git-common-dir"], + "repository Git directory", + )?; + let worktree_common = git_path_output( + worktree, + &["rev-parse", "--path-format=absolute", "--git-common-dir"], + "worktree Git directory", + )?; + if repo_common != worktree_common { + return Err( + "The requested path is no longer a worktree of this repository. Refresh and try again." + .to_string(), + ); + } + let admin_dir = git_path_output( + worktree, + &["rev-parse", "--absolute-git-dir"], + "worktree administrative directory", + )?; + let lock_path = admin_dir.join("HEAD.lock"); + let file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&lock_path) + .map_err(|e| { + if e.kind() == ErrorKind::AlreadyExists { + "Another Git operation is using this worktree. Wait for it to finish, then try again." + .to_string() + } else { + format!("Could not lock the worktree for safe removal: {e}") + } + })?; + Ok(WorktreeHeadLock { + path: lock_path, + _file: file, + }) +} + +fn exact_ref_oid_if_exists(repo: &Path, refname: &str) -> Result, String> { + let output = Command::new("git") + .current_dir(repo) + // 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() { + 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())? + .trim(); + if oid.is_empty() { + return Err("Git returned an empty branch object ID.".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> { + let output = Command::new("git") + .current_dir(repo) + .args(["for-each-ref", "--format=%(refname)%00%(upstream)", refname]) + .output() + .map_err(|e| format!("Failed to inspect branch upstream: {e}"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(if stderr.trim().is_empty() { + "Could not inspect the branch upstream.".to_string() + } else { + stderr.into_owned() + }); + } + let stdout = str::from_utf8(&output.stdout) + .map_err(|_| "Git returned an invalid UTF-8 branch upstream.".to_string())?; + for line in stdout.lines() { + let Some((candidate, upstream)) = line.split_once('\0') else { + continue; + }; + if candidate == refname { + return Ok((!upstream.is_empty()).then(|| upstream.to_string())); + } + } + Err( + "The branch disappeared while its worktree was being checked. Refresh and try again." + .to_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 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, 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)], + ) + } + }, + None => { + let (head_oid, head_verification) = head_delete_baseline(repo)?; + (head_oid, vec![head_verification]) + } + }; + + let output = Command::new("git") + .current_dir(repo) + .args(["merge-base", "--is-ancestor", &branch_oid, &base_oid]) + .output() + .map_err(|e| format!("Failed to verify whether the branch is merged: {e}"))?; + if output.status.success() { + return Ok(BranchDeleteSafety { + branch_oid, + verifications, + }); + } + if output.status.code() == Some(1) { + return Err(format!( + "Branch '{branch}' is not fully merged. Its worktree was not removed. Enable Force delete to discard unmerged commits." + )); + } + let stderr = String::from_utf8_lossy(&output.stderr); + Err(if stderr.trim().is_empty() { + "Could not verify whether the branch is fully merged; its worktree was not removed." + .to_string() + } else { + stderr.into_owned() + }) +} + +/// List every worktree registered with this repository. The main worktree is +/// always first in Git's porcelain output. Missing/prunable entries stay +/// visible but have no status, so the UI can explain why they are not removable. +pub fn list_worktrees(repo: &Path) -> Result, String> { + let output = Command::new("git") + .current_dir(repo) + .args(["worktree", "list", "--porcelain", "-z"]) + .output() + .map_err(|e| format!("Failed to list worktrees: {e}"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(if stderr.trim().is_empty() { + format!( + "git worktree list failed with exit code {:?}", + output.status.code() + ) + } else { + stderr.into_owned() + }); + } + + parse_worktree_porcelain(&output.stdout)? + .into_iter() + .enumerate() + .map(|(index, parsed)| { + let path = PathBuf::from(&parsed.path); + let is_current = same_existing_path(repo, &path); + let status = if !parsed.bare && !parsed.prunable && path.exists() { + graph::repo_status(&path).ok() + } else { + None + }; + Ok(WorktreeInfo { + path: parsed.path, + head: parsed.head, + branch: parsed.branch, + is_main: index == 0, + is_current, + detached: parsed.detached, + bare: parsed.bare, + locked: parsed.locked, + locked_reason: parsed.locked_reason, + prunable: parsed.prunable, + prunable_reason: parsed.prunable_reason, + status, + }) + }) + .collect() +} + +fn remove_linked_worktree_for_branch( + repo: &Path, + branch: &str, + requested_path: &str, + force: bool, +) -> Result { + let initial_matches: Vec = list_worktrees(repo)? + .into_iter() + .filter(|w| w.branch.as_deref() == Some(branch)) + .collect(); + if initial_matches.len() > 1 { + return Err(format!( + "Branch '{branch}' is associated with multiple worktrees; refusing an ambiguous removal." + )); + } + let initial = initial_matches.into_iter().next().ok_or_else(|| { + format!( + "The requested worktree is no longer associated with branch '{branch}'. Refresh and try again." + ) + })?; + if !same_existing_path(Path::new(&initial.path), Path::new(requested_path)) { + return Err(format!( + "The requested worktree is no longer associated with branch '{branch}'. Refresh and try again." + )); + } + if initial.is_current { + return Err( + "Cannot remove the current worktree. Open this repository from another worktree first." + .to_string(), + ); + } + if initial.is_main { + return Err("Cannot remove the repository's main worktree.".to_string()); + } + + // HEAD.lock is Git's own exclusion mechanism. Freeze the target before the + // authoritative second listing so a concurrent checkout, commit, reset, or + // merge cannot repurpose it during removal. The prepared transaction below + // separately verifies and locks the merge-safety baseline. + let _target_head_lock = acquire_worktree_head_lock(repo, Path::new(&initial.path))?; + let branch_worktrees: Vec = list_worktrees(repo)? + .into_iter() + .filter(|w| w.branch.as_deref() == Some(branch)) + .collect(); + if branch_worktrees.len() > 1 { + return Err(format!( + "Branch '{branch}' is associated with multiple worktrees; refusing an ambiguous removal." + )); + } + let worktree = branch_worktrees.into_iter().next().ok_or_else(|| { + format!( + "The requested worktree is no longer associated with branch '{branch}'. Refresh and try again." + ) + })?; + if !same_existing_path(Path::new(&worktree.path), Path::new(requested_path)) { + return Err(format!( + "The requested worktree is no longer associated with branch '{branch}'. Refresh and try again." + )); + } + 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 main worktree.".to_string()); + } + if worktree.bare || worktree.detached { + return Err( + "The selected worktree no longer has the requested branch checked out.".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 deleting the branch." + .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(), + ); + } + + 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(), + ); + } + let refname = format!("refs/heads/{branch}"); + let safety = if force { + BranchDeleteSafety { + branch_oid: exact_ref_oid(repo, &refname)?, + verifications: Vec::new(), + } + } else { + ensure_branch_safely_deletable(repo, branch)? + }; + // `prepare` atomically verifies and locks both the exact branch tip and the + // merge-safety baseline before any filesystem deletion. + let branch_delete = PreparedBranchDelete::prepare( + repo, + &refname, + &safety.branch_oid, + &safety.verifications, + )?; + // HEAD.lock prevents a concurrent checkout/commit/reset, but Git can still + // start a marker-only operation such as `git bisect start`. Re-read status + // after preparing the ref transaction and immediately before removal so an + // operation that began after the authoritative listing is still preserved. + 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"); + // Deliberately no --force: Git gets the final race-safe say and refuses if + // files become dirty after the status snapshot above. + remove + .current_dir(repo) + .args(["worktree", "remove", "--"]) + .arg(&authoritative); + git_ops::run(&mut remove)?; + branch_delete.commit().map_err(|error| { + format!( + "Removed worktree at '{}', but the prepared branch delete failed: {error}", + worktree.path + ) + })?; + // `update-ref` removes the ref and reflog; mirror `git branch -d`'s config + // cleanup. A missing section is normal for branches without local config. + let section = format!("branch.{branch}"); + let _ = Command::new("git") + .current_dir(repo) + .args(["config", "--remove-section", §ion]) + .output(); + 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. @@ -32,11 +837,11 @@ pub fn rename_branch(repo: &Path, old: &str, new: &str) -> Result<(), String> { } /// Delete a branch. `force` uses -D (drops even unmerged commits); without it, -/// -d refuses to delete a branch whose commits aren't merged into HEAD. When -/// `delete_remote` is set and a remote/branch is given, ALSO delete the upstream -/// branch via `git push --delete`. The local delete runs first; if it -/// fails the remote delete is skipped. If the local succeeds but the remote delete -/// fails, return an Err describing the partial outcome. +/// -d semantics require the branch to be merged into its upstream or HEAD. For +/// a linked worktree, a prepared ref transaction holds the exact branch and +/// safety-baseline OIDs while the clean worktree is removed. When +/// `delete_remote` is set, the local delete still runs first; a remote failure +/// is returned as an explicit partial outcome. pub fn delete_branch( repo: &Path, name: &str, @@ -44,11 +849,16 @@ pub fn delete_branch( delete_remote: bool, remote: Option<&str>, remote_branch: Option<&str>, + worktree_path: Option<&str>, ) -> Result<(), String> { - let flag = if force { "-D" } else { "-d" }; - let mut c = Command::new("git"); - c.current_dir(repo).args(["branch", flag, "--", name]); - git_ops::run(&mut c)?; + if let Some(path) = worktree_path { + remove_linked_worktree_for_branch(repo, name, path, force)?; + } else { + let flag = if force { "-D" } else { "-d" }; + let mut c = Command::new("git"); + c.current_dir(repo).args(["branch", flag, "--", name]); + git_ops::run(&mut c)?; + } if delete_remote { if let (Some(rem), Some(rb)) = (remote, remote_branch) { @@ -59,19 +869,55 @@ pub fn delete_branch( Ok(()) } -/// Delete a branch on a remote via `git push --delete `. This also -/// removes the local `refs/remotes//` tracking ref, so callers don't -/// need a separate prune. GIT_TERMINAL_PROMPT=0 so a missing credential fails instead -/// of hanging. +/// Delete a branch on a remote via `git push --delete `. On success git +/// also removes the local `refs/remotes//` tracking ref, so callers don't +/// need a separate prune. GIT_TERMINAL_PROMPT=0 so a missing credential fails instead of +/// hanging. +/// +/// Self-heal: if the remote branch is ALREADY gone (deleted elsewhere — e.g. a merged PR +/// whose branch was auto-deleted), `git push --delete` fails with "remote ref does not +/// exist" and leaves the stale local tracking ref behind, so the branch keeps showing in +/// the UI and every delete retry re-fails. Treat that specific case as success and prune +/// the stale tracking ref locally — the user's intent (make this branch go away) is met. pub fn delete_remote_branch(repo: &Path, remote: &str, branch: &str) -> Result<(), String> { let mut p = Command::new("git"); p.current_dir(repo) .env("GIT_TERMINAL_PROMPT", "0") + // Pin the C locale so the "already gone" diagnostic we match below is git's stable + // English text, not a translation from the user's LANG/LC_* — the app shells out to + // whatever git is on their PATH, which may ship localized messages. + .env("LC_ALL", "C") // Options first, then --end-of-options, so BOTH the remote name and the branch // operand are guarded against leading-dash flag injection. .args(["push", "--delete", "--end-of-options", remote, branch]); - git_ops::run(&mut p)?; - Ok(()) + match git_ops::run(&mut p) { + Ok(_) => Ok(()), + // Only the "already gone" case is safe to swallow; every other failure (auth, + // network, protected branch) must still surface — the remote ref may live on. + // The message is reliably English thanks to the LC_ALL=C pin above. + Err(e) if e.contains("remote ref does not exist") => { + prune_remote_tracking_ref(repo, remote, branch) + } + Err(e) => Err(e), + } +} + +/// Remove a stale `refs/remotes//` tracking ref (what `fetch --prune` +/// would do). No-op if the ref is already absent. The refname is built with a fixed +/// `refs/remotes/` prefix so it can never be flag-like, and `show-ref --verify` confirms +/// it resolves to exactly that ref before we delete it. +fn prune_remote_tracking_ref(repo: &Path, remote: &str, branch: &str) -> Result<(), String> { + let refname = format!("refs/remotes/{}/{}", remote, branch); + let mut check = Command::new("git"); + check + .current_dir(repo) + .args(["show-ref", "--verify", "--quiet", &refname]); + if git_ops::run(&mut check).is_err() { + return Ok(()); // already absent → nothing to prune + } + let mut del = Command::new("git"); + del.current_dir(repo).args(["update-ref", "-d", &refname]); + git_ops::run(&mut del).map(|_| ()) } /// Create a tag at `target`. With a message it's an annotated tag; otherwise lightweight. @@ -117,6 +963,9 @@ pub fn fast_forward_branch( let mut c = Command::new("git"); c.current_dir(repo) .env("GIT_TERMINAL_PROMPT", "0") + // Pin the C locale so the "non-fast-forward" / "[rejected]" diagnostic matched + // below is git's stable English text, not a translation from the user's LANG/LC_*. + .env("LC_ALL", "C") .args(["fetch", "--end-of-options", remote, &refspec]); match git_ops::run(&mut c) { Ok((o, e)) => Ok(format!("{}{}", o, e).trim().to_string()), @@ -272,14 +1121,685 @@ mod tests { r.commit("b.txt", "B"); // feat now has a commit not in main r.git(&["checkout", "-q", "main"]); assert!( - delete_branch(&r.path, "feat", false, false, None, None).is_err(), + delete_branch(&r.path, "feat", false, false, None, None, None).is_err(), "safe delete must refuse an unmerged branch" ); assert!(r.has_ref("refs/heads/feat")); - delete_branch(&r.path, "feat", true, false, None, None).unwrap(); + delete_branch(&r.path, "feat", true, false, None, None, None).unwrap(); assert!(!r.has_ref("refs/heads/feat")); } + #[test] + fn parses_worktree_porcelain_with_flags_reasons_and_spaces() { + let raw = b"worktree /tmp/main repo\0HEAD aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\0branch refs/heads/main\0\0worktree /tmp/linked repo\0HEAD bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\0branch refs/heads/feature/worktree\0locked in use by another process\0prunable gitdir file points to non-existent location\0\0worktree /tmp/detached\0HEAD cccccccccccccccccccccccccccccccccccccccc\0detached\0\0"; + + let parsed = parse_worktree_porcelain(raw).unwrap(); + + assert_eq!(parsed.len(), 3); + assert_eq!(parsed[0].path, "/tmp/main repo"); + assert_eq!(parsed[0].branch.as_deref(), Some("main")); + assert_eq!(parsed[1].path, "/tmp/linked repo"); + assert_eq!(parsed[1].branch.as_deref(), Some("feature/worktree")); + assert!(parsed[1].locked); + assert_eq!( + parsed[1].locked_reason.as_deref(), + Some("in use by another process") + ); + assert!(parsed[1].prunable); + assert_eq!( + parsed[1].prunable_reason.as_deref(), + Some("gitdir file points to non-existent location") + ); + assert!(parsed[2].detached); + assert_eq!(parsed[2].branch, None); + } + + #[test] + fn list_worktrees_reports_main_linked_branch_and_dirty_status() { + let r = TempRepo::new(); + r.commit("a.txt", "A"); + let wt = r.path.join("linked worktree"); + r.git(&[ + "worktree", + "add", + "-q", + "-b", + "feature", + wt.to_str().unwrap(), + "main", + ]); + fs::write(wt.join("dirty.txt"), "dirty").unwrap(); + + let worktrees = list_worktrees(&r.path).unwrap(); + + assert_eq!(worktrees.len(), 2); + assert!(worktrees[0].is_main); + assert!(worktrees[0].is_current); + let linked = worktrees + .iter() + .find(|w| w.branch.as_deref() == Some("feature")) + .unwrap(); + assert!(same_existing_path(Path::new(&linked.path), &wt)); + assert!(!linked.is_main); + assert!(!linked.is_current); + assert_eq!(linked.status.as_ref().map(|s| s.untracked), Some(1)); + } + + #[test] + fn delete_branch_can_remove_its_clean_linked_worktree_first() { + 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("feature.txt"), "published feature").unwrap(); + r.git(&["-C", wt.to_str().unwrap(), "add", "feature.txt"]); + r.git(&[ + "-C", + wt.to_str().unwrap(), + "commit", + "-q", + "-m", + "published feature", + ]); + r.git(&[ + "update-ref", + "refs/remotes/origin/feature", + "refs/heads/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", + ]); + r.git(&["config", "branch.feature.description", "temporary branch"]); + + 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")); + let config = Command::new("git") + .current_dir(&r.path) + .args(["config", "--get", "branch.feature.description"]) + .output() + .unwrap(); + 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(); + 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"); + + let transaction = PreparedBranchDelete::prepare( + &r.path, + "refs/heads/feature", + &expected, + &safety.verifications, + ) + .unwrap(); + let attempted_move = Command::new("git") + .current_dir(&r.path) + .args([ + "update-ref", + "refs/heads/feature", + &replacement, + &expected, + ]) + .output() + .unwrap(); + + assert!(!attempted_move.status.success(), "prepared ref must stay locked"); + let attempted_baseline_rewind = Command::new("git") + .current_dir(&r.path) + .args(["update-ref", "refs/heads/main", &expected, &replacement]) + .output() + .unwrap(); + assert!( + !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] + fn safe_delete_preserves_unmerged_linked_worktree_and_ignored_files() { + let r = TempRepo::new(); + r.commit(".gitignore", "ignored.log\n"); + let wt = r.path.join("unmerged-linked"); + r.git(&[ + "worktree", + "add", + "-q", + "-b", + "feature", + wt.to_str().unwrap(), + "main", + ]); + fs::write(wt.join("feature.txt"), "unmerged commit").unwrap(); + r.git(&["-C", wt.to_str().unwrap(), "add", "feature.txt"]); + r.git(&[ + "-C", + wt.to_str().unwrap(), + "commit", + "-q", + "-m", + "feature commit", + ]); + fs::write(wt.join("ignored.log"), "must survive failed deletion").unwrap(); + + 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.join("ignored.log").exists()); + assert!(r.has_ref("refs/heads/feature")); + let admin_dir = git_path_output( + &wt, + &["rev-parse", "--absolute-git-dir"], + "test worktree administrative directory", + ) + .unwrap(); + assert!(!admin_dir.join("HEAD.lock").exists()); + } + + #[test] + fn delete_branch_refuses_a_worktree_with_an_active_git_head_lock() { + let r = TempRepo::new(); + r.commit("a.txt", "A"); + let wt = r.path.join("busy-linked"); + r.git(&[ + "worktree", + "add", + "-q", + "-b", + "feature", + wt.to_str().unwrap(), + "main", + ]); + let admin_dir = git_path_output( + &wt, + &["rev-parse", "--absolute-git-dir"], + "test worktree administrative directory", + ) + .unwrap(); + fs::write(admin_dir.join("HEAD.lock"), "another git process").unwrap(); + + let err = delete_branch( + &r.path, + "feature", + true, + false, + None, + None, + Some(wt.to_str().unwrap()), + ) + .unwrap_err(); + + assert!( + err.contains("Another Git operation"), + "unexpected error: {err}" + ); + assert!(wt.exists()); + assert!(r.has_ref("refs/heads/feature")); + } + + #[test] + fn delete_branch_refuses_dirty_linked_worktree_without_mutation() { + 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"), "keep me").unwrap(); + + let err = delete_branch( + &r.path, + "feature", + true, + false, + None, + None, + Some(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 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(); + 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(&[ + "worktree", + "lock", + "--reason", + "owned by another task", + wt.to_str().unwrap(), + ]); + + let err = delete_branch( + &r.path, + "feature", + true, + false, + None, + None, + Some(wt.to_str().unwrap()), + ) + .unwrap_err(); + + assert!(err.contains("locked"), "unexpected error: {err}"); + assert!( + err.contains("owned by another task"), + "unexpected error: {err}" + ); + assert!(wt.exists()); + assert!(r.has_ref("refs/heads/feature")); + } + + #[test] + fn delete_branch_revalidates_worktree_path_and_branch() { + 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", + ]); + let wrong = r.path.join("not-the-linked-worktree"); + + let err = delete_branch( + &r.path, + "feature", + true, + false, + None, + None, + Some(wrong.to_str().unwrap()), + ) + .unwrap_err(); + + assert!( + err.contains("no longer associated"), + "unexpected error: {err}" + ); + assert!(wt.exists()); + assert!(r.has_ref("refs/heads/feature")); + } + + #[test] + fn delete_branch_never_removes_the_current_main_worktree() { + let r = TempRepo::new(); + r.commit("a.txt", "A"); + + let err = delete_branch( + &r.path, + "main", + true, + false, + None, + None, + Some(r.path.to_str().unwrap()), + ) + .unwrap_err(); + + assert!(err.contains("current worktree"), "unexpected error: {err}"); + assert!(r.path.exists()); + assert!(r.has_ref("refs/heads/main")); + } + #[test] fn rename_branch_moves_ref() { let r = TempRepo::new(); @@ -510,7 +2030,7 @@ mod tests { work.git(&["checkout", "-q", "main"]); // Delete local + remote feature. - let res = delete_branch(&work.path, "feature", true, true, Some("origin"), Some("feature")); + let res = delete_branch(&work.path, "feature", true, true, Some("origin"), Some("feature"), None); assert!(res.is_ok(), "{:?}", res); // Local branch must be gone. @@ -587,6 +2107,57 @@ mod tests { let _ = fs::remove_dir_all(&clone_path); } + #[test] + fn delete_remote_branch_self_heals_when_remote_already_gone() { + // A merged PR whose branch was auto-deleted on the remote leaves a STALE local + // remote-tracking ref (only `fetch --prune` clears it). "Delete remote branch" + // then runs `git push --delete`, which fails with "remote ref does not exist" — + // so the phantom never went away and every retry re-failed (user-reported). + // delete_remote_branch must treat that as success and prune the stale ref locally. + let bare = unique_dir("selfheal-bare"); + fs::create_dir_all(&bare).unwrap(); + Command::new("git") + .current_dir(&bare) + .args(["init", "-q", "--bare"]) + .output() + .unwrap(); + + let upstream = TempRepo::new(); + upstream.commit("a.txt", "c1"); + upstream.git(&["branch", "feature"]); + upstream.git(&["remote", "add", "origin", bare.to_str().unwrap()]); + upstream.git(&["push", "-q", "origin", "main", "feature"]); + + let clone_path = unique_dir("selfheal-clone"); + Command::new("git") + .args(["clone", "-q", bare.to_str().unwrap(), clone_path.to_str().unwrap()]) + .output() + .unwrap(); + let clone = TempRepo { path: clone_path.clone() }; + + // Simulate the remote-side delete WITHOUT pruning the clone: drop feature on the + // bare remote directly. The clone still shows refs/remotes/origin/feature (stale). + Command::new("git") + .args(["--git-dir", bare.to_str().unwrap(), "update-ref", "-d", "refs/heads/feature"]) + .output() + .unwrap(); + assert!( + clone.has_ref("refs/remotes/origin/feature"), + "precondition: clone still has the stale tracking ref" + ); + + // The natural user action (delete the phantom) must now SUCCEED, not error. + let res = delete_remote_branch(&clone.path, "origin", "feature"); + assert!(res.is_ok(), "self-heal expected Ok, got {:?}", res); + assert!( + !clone.has_ref("refs/remotes/origin/feature"), + "stale tracking ref must be pruned after self-heal" + ); + + let _ = fs::remove_dir_all(&bare); + let _ = fs::remove_dir_all(&clone_path); + } + #[test] fn branch_subjects_lists_newest_first_and_respects_limit() { let r = TempRepo::new(); diff --git a/crates/git-core/src/ops_remote.rs b/crates/git-core/src/ops_remote.rs index 8aa6202..a1245c5 100644 --- a/crates/git-core/src/ops_remote.rs +++ b/crates/git-core/src/ops_remote.rs @@ -196,6 +196,12 @@ pub fn stream( } let _busy = BusyGuard(&state.busy); + // Pin the C locale so the combined output classified after this run + // (looks_like_auth_failure, plus any caller-side stderr matching) is git's stable + // English text, not a translation from the user's LANG/LC_*. Every credentialed + // push/pull streams through here, so this is the one place that guarantees parseable + // messages for all of them. + cmd.env("LC_ALL", "C"); cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); let mut child = cmd.spawn().map_err(|e| format!("spawn: {}", e))?; diff --git a/crates/git-core/src/types.rs b/crates/git-core/src/types.rs index 7d969da..c7c01d5 100644 --- a/crates/git-core/src/types.rs +++ b/crates/git-core/src/types.rs @@ -111,6 +111,34 @@ 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. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorktreeInfo { + pub path: String, + pub head: Option, + pub branch: Option, + pub is_main: bool, + pub is_current: bool, + pub detached: bool, + pub bare: bool, + pub locked: bool, + pub locked_reason: Option, + pub prunable: bool, + pub prunable_reason: Option, + pub status: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OpOutcome { /// True when the operation stopped on conflicts (not an error — needs resolution). diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 9389cdf..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}; +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, @@ -159,6 +179,16 @@ pub fn list_refs(repo: String) -> Result, String> { graph::list_refs(&PathBuf::from(repo)) } +#[tauri::command(async)] +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)) @@ -189,6 +219,7 @@ pub fn delete_branch( delete_remote: Option, remote: Option, remote_branch: Option, + worktree_path: Option, ) -> Result<(), String> { ops::delete_branch( &PathBuf::from(repo), @@ -197,6 +228,7 @@ pub fn delete_branch( delete_remote.unwrap_or(false), remote.as_deref(), remote_branch.as_deref(), + worktree_path.as_deref(), ) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b02d87f..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,9 +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 3889975..7729629 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -40,14 +40,19 @@ import type { WorkingFile, 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; } @@ -56,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 }), @@ -69,6 +85,9 @@ export const api = { loadGraph: (repo: string, count: number, skip = 0) => 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) => @@ -82,12 +101,14 @@ export const api = { deleteRemote?: boolean, remote?: string, remoteBranch?: string, + worktreePath?: string, ) => invoke("delete_branch", { repo, name, force, deleteRemote: deleteRemote ?? false, remote: remote ?? null, remoteBranch: remoteBranch ?? null, + worktreePath: worktreePath ?? null, }), createTag: (repo: string, name: string, target: string, message?: string) => invoke("create_tag", { repo, name, target, message: message ?? null }), 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"} @@ -134,7 +177,13 @@ >

+ +{:else if dialogs.state.kind === "newRepository"} + +
{ + if (e.target === e.currentTarget) dialogs.resolveNewRepository(false); + }} + onkeydown={handleNewRepositoryKey} + > + @@ -381,6 +535,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/RepoList.svelte b/src/lib/components/RepoList.svelte index e4d7d4e..c9cdc3c 100644 --- a/src/lib/components/RepoList.svelte +++ b/src/lib/components/RepoList.svelte @@ -1,6 +1,7 @@ + + + + + diff --git a/src/lib/components/WorktreePanel.svelte b/src/lib/components/WorktreePanel.svelte new file mode 100644 index 0000000..920ff34 --- /dev/null +++ b/src/lib/components/WorktreePanel.svelte @@ -0,0 +1,186 @@ + + +
+ {#each ordered as worktree (worktree.path)} +
openContextMenu(event, worktree)} + > + + + {label(worktree)} + {worktree.path} + + {#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/components/github/GithubHeader.svelte b/src/lib/components/github/GithubHeader.svelte index 3f7f103..70b7501 100644 --- a/src/lib/components/github/GithubHeader.svelte +++ b/src/lib/components/github/GithubHeader.svelte @@ -38,7 +38,7 @@ title={githubState.anyLoading ? "Refreshing…" : "Refresh"} aria-busy={githubState.anyLoading} onclick={onrefresh} - >↻ + > @@ -71,6 +71,9 @@ } .refresh { margin-left: auto; + display: inline-flex; + align-items: center; + justify-content: center; width: 30px; height: 30px; border: 1px solid var(--border); @@ -84,10 +87,18 @@ .refresh:hover { border-color: var(--accent); } - /* Spin the ↻ glyph while any github fetch is in flight — the only feedback on a - soft refresh, which keeps stale data on screen (no skeleton). */ + /* inline-block so the glyph can be rotated on its own; the button box stays put. */ + .refresh .glyph { + display: inline-block; + line-height: 1; + } + /* Spin ONLY the ↻ glyph — not the whole button chrome (border + background) — while + any github fetch is in flight; the sole feedback on a soft refresh, which keeps + stale data on screen (no skeleton). */ .refresh.spinning { color: var(--accent); + } + .refresh.spinning .glyph { animation: gh-refresh-spin 0.8s linear infinite; } @keyframes gh-refresh-spin { @@ -96,8 +107,10 @@ } } @media (prefers-reduced-motion: reduce) { - .refresh.spinning { + .refresh.spinning .glyph { animation: none; + } + .refresh.spinning { opacity: 0.6; } } 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 d2917ed..ffaede2 100644 --- a/src/lib/dialogs.svelte.ts +++ b/src/lib/dialogs.svelte.ts @@ -1,8 +1,34 @@ // 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; +}; + +export type NewRepositoryResult = { + name: string; + initialBranch: string; + createRemote: boolean; + isPrivate: boolean; + description: string; +}; + type DialogState = | { kind: "none" } + | { + // A message-only error dialog: single "OK" button, nothing to confirm. Surfaces a + // failed op unmissably instead of only in the easy-to-miss status bar. + kind: "alert"; + title: string; + message: string; + resolve: () => void; + } | { kind: "prompt"; title: string; @@ -16,6 +42,7 @@ type DialogState = kind: "confirm"; title: string; message: string; + messageFormat: "text" | "markdown"; confirmLabel: string; danger: boolean; resolve: (v: boolean) => void; @@ -44,7 +71,20 @@ 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: "newRepository"; + title: string; + parent: string; + name: string; + initialBranch: string; + createRemote: boolean; + isPrivate: boolean; + description: string; + resolve: (v: NewRepositoryResult | null) => void; } | { kind: "createRepo"; @@ -75,9 +115,11 @@ 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 === "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(); } return { @@ -107,6 +149,7 @@ function makeDialogs() { confirm(opts: { title: string; message: string; + messageFormat?: "text" | "markdown"; confirmLabel?: string; danger?: boolean; }): Promise { @@ -116,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, @@ -164,6 +208,21 @@ function makeDialogs() { state = { kind: "none" }; } }, + // ── Alert dialog ────────────────────────────────────────────────────────── + // Message-only "something failed" modal. The returned promise resolves when the + // dialog is dismissed; callers can fire-and-forget (nothing waits on it). + alert(opts: { title: string; message: string }): Promise { + settlePending(); + return new Promise((resolve) => { + state = { kind: "alert", title: opts.title, message: opts.message, resolve }; + }); + }, + resolveAlert() { + if (state.kind === "alert") { + state.resolve(); + state = { kind: "none" }; + } + }, // ── Credentials dialog (Phase 6) ────────────────────────────────────────── // Opens a username + password prompt for a network op that returned authFailed. // The resolved value is used ONCE for the retry; it is NEVER stored in app state. @@ -194,18 +253,51 @@ 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" }; + } + }, + // ── 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" }; } }, 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.partialFailure.test.ts b/src/lib/gitActions.partialFailure.test.ts new file mode 100644 index 0000000..2a7660c --- /dev/null +++ b/src/lib/gitActions.partialFailure.test.ts @@ -0,0 +1,431 @@ +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("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); + 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 254f1ee..5440ea3 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); @@ -38,16 +48,26 @@ 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, 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); + else console.warn("[gte] worktrees refresh failed", worktrees.reason); } // ── Live working-copy watching (filesystem watcher + focus refresh) ─────────── @@ -104,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/ @@ -186,59 +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.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; - 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. @@ -302,7 +434,19 @@ async function run(label: string, fn: () => Promise): Promise appState.status = `${label} — done.`; return true; } catch (e) { + // A partial success (e.g. the local branch was deleted but its remote delete failed) + // 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. + void dialogs.alert({ title: `${label} failed`, message: errorText(e) }); return false; } finally { appState.setNavBusy(false); @@ -310,15 +454,19 @@ async function run(label: string, fn: () => Promise): Promise } } -// Git error/output text is often multiline; toasts get only the first line (contract d). -// GitHub commands reject with a serialized GithubError OBJECT ({kind, message?}) — String() -// on that yields "[object Object]", so unwrap it first (mirrors errText in githubActions). -function firstLine(e: unknown): string { +// Full error text. GitHub commands reject with a serialized GithubError OBJECT +// ({kind, message?}) — String() on that yields "[object Object]", so unwrap it first +// (mirrors errText in githubActions). Used verbatim in the error dialog. +function errorText(e: unknown): string { if (e && typeof e === "object" && "kind" in e) { const g = e as { kind: string; message?: string }; - return String(g.kind === "Other" ? (g.message ?? g.kind) : g.kind).split("\n")[0]; + return String(g.kind === "Other" ? (g.message ?? g.kind) : g.kind); } - return String(e).split("\n")[0]; + return String(e); +} +// First line only — git output is often multiline; the compact status bar gets one line. +function firstLine(e: unknown): string { + return errorText(e).split("\n")[0]; } // Run an OpOutcome-returning op. Always refreshes graph+status (even on error) so @@ -555,16 +703,46 @@ 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, - ) => - run(`Delete branch ${name}`, () => - api.deleteBranch(appState.repo, name, force, deleteRemote, remote, remoteBranch), - ), + worktreePath?: string, + ) => { + 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 () => { + 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/github/activity.test.ts b/src/lib/github/activity.test.ts index dd87a46..65426a6 100644 --- a/src/lib/github/activity.test.ts +++ b/src/lib/github/activity.test.ts @@ -1,5 +1,6 @@ -import { describe, it, expect } from "vitest"; -import { sparklinePoints } from "./activity"; +import { describe, it, expect, vi } from "vitest"; +import { sparklinePoints, fetchActivityWithRetry, isRetryableActivityError } from "./activity"; +import type { GhActivity } from "../types"; describe("sparklinePoints", () => { it("maps values to a polyline string, max at the top (y=0)", () => { @@ -12,3 +13,72 @@ describe("sparklinePoints", () => { expect(sparklinePoints([3, 3], 10, 10)).toBe("0.0,0.0 10.0,0.0"); }); }); + +describe("isRetryableActivityError", () => { + it("retries only transient cold-cache failures", () => { + expect(isRetryableActivityError({ kind: "Other", message: "parse activity" })).toBe(true); + expect(isRetryableActivityError("network blip")).toBe(true); // non-GithubError → retry + }); + it("does not retry explicit rate limits or permanent failures", () => { + for (const kind of ["RateLimited", "Forbidden", "NotFound", "NotAuthed", "NoRemote", "NotInstalled"]) { + expect(isRetryableActivityError({ kind })).toBe(false); + } + }); +}); + +describe("fetchActivityWithRetry", () => { + const ready: GhActivity = { computing: false, weeks: [{ week: 1, total: 2, days: [] }] }; + const computing: GhActivity = { computing: true, weeks: [] }; + const noSleep = () => Promise.resolve(); + + it("returns data on the first try when stats are ready", async () => { + const fetchOnce = vi.fn().mockResolvedValue(ready); + const out = await fetchActivityWithRetry(fetchOnce, noSleep); + expect(out).toEqual(ready); + expect(fetchOnce).toHaveBeenCalledTimes(1); + }); + + it("retries while computing, then returns the data once it's ready", async () => { + const fetchOnce = vi + .fn() + .mockResolvedValueOnce(computing) + .mockResolvedValueOnce(computing) + .mockResolvedValueOnce(ready); + const out = await fetchActivityWithRetry(fetchOnce, noSleep); + expect(out).toEqual(ready); + expect(fetchOnce).toHaveBeenCalledTimes(3); + }); + + it("retries through a TRANSIENT THROW (the regression) and recovers", async () => { + // The old code retried only `computing:true`; a thrown cold-cache error hard-errored + // the panel. This must now retry the throw and still resolve to data. + const fetchOnce = vi + .fn() + .mockRejectedValueOnce({ kind: "Other", message: "parse activity: expected array" }) + .mockResolvedValueOnce(ready); + const out = await fetchActivityWithRetry(fetchOnce, noSleep); + expect(out).toEqual(ready); + expect(fetchOnce).toHaveBeenCalledTimes(2); + }); + + it("resolves to the computing marker (soft note, not error) when attempts run out", async () => { + const fetchOnce = vi.fn().mockResolvedValue(computing); + const out = await fetchActivityWithRetry(fetchOnce, noSleep, 3); + expect(out).toEqual({ computing: true, weeks: [] }); + expect(fetchOnce).toHaveBeenCalledTimes(3); + }); + + it("aborts immediately on a non-retryable error (permanent OR explicit rate limit)", async () => { + for (const kind of ["NotFound", "RateLimited"]) { + const fetchOnce = vi.fn().mockRejectedValue({ kind }); + await expect(fetchActivityWithRetry(fetchOnce, noSleep, 5)).rejects.toEqual({ kind }); + expect(fetchOnce).toHaveBeenCalledTimes(1); + } + }); + + it("propagates the last error when a transient (Other) failure never clears", async () => { + const fetchOnce = vi.fn().mockRejectedValue({ kind: "Other", message: "parse activity" }); + await expect(fetchActivityWithRetry(fetchOnce, noSleep, 3)).rejects.toMatchObject({ kind: "Other" }); + expect(fetchOnce).toHaveBeenCalledTimes(3); + }); +}); diff --git a/src/lib/github/activity.ts b/src/lib/github/activity.ts index 882f64c..1531e5d 100644 --- a/src/lib/github/activity.ts +++ b/src/lib/github/activity.ts @@ -1,3 +1,5 @@ +import type { GhActivity, GithubError } from "../types"; + /** Map a numeric series to an SVG polyline `points` string in a `w`×`h` box. * The max value sits at the top (y=0); an all-equal series sits on y=0. */ export function sparklinePoints(values: number[], w: number, h: number): string { @@ -8,3 +10,48 @@ export function sparklinePoints(values: number[], w: number, h: number): string .map((v, i) => `${(i * dx).toFixed(1)},${(h - (v / max) * h).toFixed(1)}`) .join(" "); } + +/** Only a transient cold-cache failure is worth the short fixed backoff. GitHub computes + * /stats/commit_activity lazily (HTTP 202 the first time), which can surface as an + * unparseable placeholder body — an `Other`/parse error — or a non-GithubError network + * blip. Explicit terminal states are NOT retried: `RateLimited` won't clear inside the + * backoff window (and re-hitting it prolongs the throttle), and Forbidden / NotFound / + * NotAuthed / NoRemote / NotInstalled are permanent. Reserve the retries for the + * computing/parse case; a real rate limit should surface (and wait for its reset) instead. */ +export function isRetryableActivityError(e: unknown): boolean { + if (e && typeof e === "object" && "kind" in e) { + return (e as GithubError).kind === "Other"; + } + return true; // non-GithubError throw → treat as a transient blip +} + +/** Fetch commit activity, retrying through GitHub's lazy-computation window. + * + * The first request on a cold cache returns HTTP 202 while GitHub builds the 52-week + * series; depending on timing that reaches us as `computing: true`, an unparseable body, + * or a transient error. This retries ALL of those a few times with a fixed backoff — + * the previous logic retried only the `computing` flag, so a *thrown* cold-cache error + * hard-errored the Insights panel until the user manually switched tabs and back (which + * forced a re-fetch). A clearly-permanent error aborts early; if the stats are still + * computing when attempts run out, we resolve to the `computing` marker so the UI shows + * the soft "still computing…" note rather than a hard error. + * + * Dependencies (`fetchOnce`, `sleep`) are injected so the retry policy is unit-testable. */ +export async function fetchActivityWithRetry( + fetchOnce: () => Promise, + sleep: (ms: number) => Promise, + attempts = 5, + delayMs = 1500, +): Promise { + for (let i = 0; i < attempts; i++) { + if (i > 0) await sleep(delayMs); + try { + const a = await fetchOnce(); + if (!a.computing) return a; // stats are ready + // else: still computing → fall through to the next attempt + } catch (e) { + if (i === attempts - 1 || !isRetryableActivityError(e)) throw e; + } + } + return { computing: true, weeks: [] }; +} diff --git a/src/lib/githubState.svelte.ts b/src/lib/githubState.svelte.ts index 0d1cca8..88e3d48 100644 --- a/src/lib/githubState.svelte.ts +++ b/src/lib/githubState.svelte.ts @@ -1,6 +1,7 @@ import { appState } from "./store.svelte"; import { api } from "./api"; import { parseGithubRemote } from "./github/remote"; +import { fetchActivityWithRetry } from "./github/activity"; import type { GhAvailability, GhRepoStats, @@ -125,16 +126,17 @@ function makeGithubState() { return contributors.load(`${repo}|${reloadNonce}`, () => api.githubContributors(repo, 12)); } function loadActivity(repo: string) { - return activity.load(`${repo}|${reloadNonce}`, async () => { - // /stats/commit_activity returns 202 + empty body while GitHub computes; - // retry a few times before giving up and showing the "computing" state. - let a = await api.githubActivity(repo); - for (let tries = 0; a.computing && tries < 3; tries++) { - await new Promise((r) => setTimeout(r, 1800)); - a = await api.githubActivity(repo); - } - return a; - }); + // /stats/commit_activity is computed lazily by GitHub (HTTP 202 on a cold cache), so + // the first request can come back "computing", unparseable, or as a transient error. + // fetchActivityWithRetry retries through ALL of those; previously only the `computing` + // flag was retried, so a thrown cold-cache error hard-errored the panel until the user + // switched tabs and back (which forced a re-fetch — the reported symptom). + return activity.load(`${repo}|${reloadNonce}`, () => + fetchActivityWithRetry( + () => api.githubActivity(repo), + (ms) => new Promise((r) => setTimeout(r, ms)), + ), + ); } function loadMilestones(repo: string) { return milestones.load(`${repo}|${reloadNonce}`, () => api.githubMilestones(repo)); diff --git a/src/lib/prerequisites.svelte.ts b/src/lib/prerequisites.svelte.ts new file mode 100644 index 0000000..522e224 --- /dev/null +++ b/src/lib/prerequisites.svelte.ts @@ -0,0 +1,69 @@ +// Shared singleton for the prerequisite probe (git / python3 / bundled git-filter-repo). +// Lifted out of PrereqBanner so the editing controls (ApplyPanel) gate on the SAME result +// instead of the banner merely *claiming* commit-time editing is disabled while the +// destructive "Rewrite history" action stays reachable. One probe, shared across callers; +// load() dedupes concurrent callers (banner + apply panel) and retries after a failure. +import { api } from "./api"; +import type { PrerequisiteCheck } from "./types"; + +function isTauri(): boolean { + return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; +} + +function makePrerequisites() { + let check = $state(null); + let loading: Promise | null = null; + // Monotonic probe id. refresh() can start a probe while another is still in flight, so + // only the LATEST probe may commit its result — otherwise a stale/out-of-order completion + // (e.g. an older failure resolving after a newer success) would clobber the current state + // and wrongly re-disable editing. + let probeSeq = 0; + + async function probe() { + // Non-Tauri (browser preview) has no backend to probe — leave the state "unknown" + // (not a failure) so canEditHistory stays optimistic there. + if (!isTauri()) return; + const seq = ++probeSeq; + try { + const result = await api.checkPrerequisites(); + if (seq !== probeSeq) return; // superseded by a newer probe — drop this result + check = result; + } catch { + if (seq !== probeSeq) return; // superseded by a newer probe — drop this stale failure + check = null; + // Clear the cached promise so a later load()/refresh() re-probes a transient failure + // instead of no-op'ing on this (resolved) promise for the rest of the session. + loading = null; + } + } + + return { + get check() { + return check; + }, + // Commit-time editing needs a SUCCESSFUL probe confirming git + python3 + a working + // bundled filter-repo. Until one arrives, the DESKTOP app keeps the destructive "Rewrite + // history" action DISABLED — a slow OR failed probe must never leave it invokable on + // unverified prerequisites. A non-Tauri preview has nothing to verify (editing is inert + // there anyway), so it stays optimistic. A later successful probe enables it. + get canEditHistory() { + if (check) return check.git && check.python3 && check.filterRepo; + return !isTauri(); + }, + // Probe once, then share the result — deduped so the banner and ApplyPanel don't + // double-invoke. + load() { + if (!loading) loading = probe(); + return loading; + }, + // Force a fresh probe. Used when the user returns to the app after fixing a + // prerequisite (e.g. finishing the async Command Line Tools installer), so the + // banner clears and the editing controls re-enable without a restart. + refresh() { + loading = probe(); + return loading; + }, + }; +} + +export const prerequisites = makePrerequisites(); diff --git a/src/lib/repositoryFlows.test.ts b/src/lib/repositoryFlows.test.ts new file mode 100644 index 0000000..206c858 --- /dev/null +++ b/src/lib/repositoryFlows.test.ts @@ -0,0 +1,172 @@ +// @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(), + refreshRefs: 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("./gitActions", () => ({ refreshRefs: mocks.refreshRefs })); +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.refreshRefs.mockReset().mockResolvedValue(undefined); + mocks.openRepo.mockReset().mockImplementation((path: string) => { + mocks.appState.repo = path; + }); + 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", + ); + expect(mocks.refreshRefs).toHaveBeenCalledOnce(); + }); + + 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"), + })); + expect(mocks.refreshRefs).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/repositoryFlows.ts b/src/lib/repositoryFlows.ts new file mode 100644 index 0000000..8e9c990 --- /dev/null +++ b/src/lib/repositoryFlows.ts @@ -0,0 +1,136 @@ +import { api, pickRepoFolder } from "./api"; +import { dialogs } from "./dialogs.svelte"; +import { refreshRefs } from "./gitActions"; +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, + ); + // 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); + 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 bf8b542..185ff36 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 { @@ -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; @@ -1394,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; @@ -1585,9 +1605,16 @@ 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([]); + // 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); // True from the moment a repo switch begins until that repo's reloadGraph finishes. @@ -1607,7 +1634,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, ); @@ -1637,6 +1664,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. @@ -1644,6 +1672,7 @@ function makeState() { workingChanges = []; workingChangesRev = 0; refsDetailed = []; + worktrees = []; remotesState = []; graphCommits = []; commits = []; @@ -2175,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. @@ -2210,10 +2245,20 @@ 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; + }, + setWorktrees(v: WorktreeInfo[]) { + worktrees = v; }, get remotes() { return remotesState; @@ -2325,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/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/types.ts b/src/lib/types.ts index f97a496..45c3bd2 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -98,6 +98,27 @@ 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 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 new file mode 100644 index 0000000..ef9fc4a --- /dev/null +++ b/src/lib/worktrees.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; +import type { Ref, WorktreeInfo } from "./types"; +import { + linkedWorktreeForBranch, + linkedWorktrees, + worktreeForBranch, + worktreeIsDirty, + worktreeRemovalBlocker, + worktreeStatusDetail, + worktreeStatusLabel, + worktreeTrackingDetail, +} 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("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); + 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", () => { + expect(worktreeRemovalBlocker(worktree())).toBeNull(); + expect(worktreeRemovalBlocker(worktree({ isCurrent: true }))).toContain("currently open"); + 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"); + 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", () => { + 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 new file mode 100644 index 0000000..f5212b4 --- /dev/null +++ b/src/lib/worktrees.ts @@ -0,0 +1,114 @@ +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[], + branch: string, +): WorktreeInfo | undefined { + 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 && ( + 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 "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."; + } + 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.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."; + } + 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 (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;