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 22542e3..36b1783 100644 --- a/crates/git-core/src/ops.rs +++ b/crates/git-core/src/ops.rs @@ -1,6 +1,690 @@ 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) +} /// 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 +716,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 +728,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) { @@ -311,14 +1000,620 @@ 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 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(); @@ -549,7 +1844,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. diff --git a/crates/git-core/src/types.rs b/crates/git-core/src/types.rs index 7d969da..a0d8ca7 100644 --- a/crates/git-core/src/types.rs +++ b/crates/git-core/src/types.rs @@ -111,6 +111,26 @@ pub struct RepoStatus { pub operation: Option, } +/// 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..75841ce 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -7,7 +7,7 @@ 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, 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; @@ -159,6 +159,11 @@ 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] pub fn repo_status(repo: String) -> Result { graph::repo_status(&PathBuf::from(repo)) @@ -189,6 +194,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 +203,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..c4a994f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -115,6 +115,7 @@ pub fn run() { commands::load_commits, commands::load_graph, commands::list_refs, + commands::list_worktrees, commands::repo_status, commands::checkout, commands::create_branch, diff --git a/src/lib/api.ts b/src/lib/api.ts index 3889975..1de32e6 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -40,6 +40,7 @@ import type { WorkingFile, UpdateInfo, DownloadEvent, + WorktreeInfo, } from "./types"; export async function pickRepoFolder(initial?: string): Promise { @@ -69,6 +70,7 @@ 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 }), 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 +84,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/Modal.svelte b/src/lib/components/Modal.svelte index bbaf1af..6aaacfa 100644 --- a/src/lib/components/Modal.svelte +++ b/src/lib/components/Modal.svelte @@ -1,17 +1,30 @@ + + + + + diff --git a/src/lib/components/WorktreePanel.svelte b/src/lib/components/WorktreePanel.svelte new file mode 100644 index 0000000..040245e --- /dev/null +++ b/src/lib/components/WorktreePanel.svelte @@ -0,0 +1,107 @@ + + +
+ {#each ordered as worktree (worktree.path)} +
+ + + {label(worktree)} + {worktree.path} + + {#if worktreeStatusLabel(worktree)} + {worktreeStatusLabel(worktree)} + {/if} +
+ {/each} +
+ + diff --git a/src/lib/dialogs.svelte.ts b/src/lib/dialogs.svelte.ts index d03cda0..c684271 100644 --- a/src/lib/dialogs.svelte.ts +++ b/src/lib/dialogs.svelte.ts @@ -1,6 +1,16 @@ // Promise-based modal dialogs so callers can `await dialogs.prompt(...)` / // `await dialogs.confirm(...)` inline. A single mounted once renders // whichever dialog is active. +import type { WorktreeInfo } from "./types"; +import { worktreeRemovalBlocker } from "./worktrees"; + +type BranchDeleteResult = { + confirmed: boolean; + force: boolean; + deleteRemote: boolean; + removeWorktree: boolean; +}; + type DialogState = | { kind: "none" } | { @@ -52,7 +62,9 @@ type DialogState = remoteGone: boolean; // tracking config exists but the remote branch was already deleted force: boolean; deleteRemote: boolean; - resolve: (v: { confirmed: boolean; force: boolean; deleteRemote: boolean }) => void; + worktree: WorktreeInfo | null; + removeWorktree: boolean; + resolve: (v: BranchDeleteResult) => void; } | { kind: "createRepo"; @@ -83,7 +95,7 @@ function makeDialogs() { else if (state.kind === "confirm") state.resolve(false); else if (state.kind === "destructive") state.resolve({ confirmed: false, backup: false }); else if (state.kind === "credentials") state.resolve(null); - else if (state.kind === "branchDelete") state.resolve({ confirmed: false, force: false, deleteRemote: false }); + else if (state.kind === "branchDelete") state.resolve({ confirmed: false, force: false, deleteRemote: false, removeWorktree: false }); else if (state.kind === "createRepo") state.resolve({ confirmed: false, name: "", isPrivate: true, description: "" }); else if (state.kind === "createPr") state.resolve(null); else if (state.kind === "alert") state.resolve(); @@ -218,18 +230,22 @@ function makeDialogs() { } }, // ── Branch delete dialog ────────────────────────────────────────────────── - confirmBranchDelete(opts: { branch: string; upstream: string | null; remoteGone?: boolean }): Promise<{ confirmed: boolean; force: boolean; deleteRemote: boolean }> { + confirmBranchDelete(opts: { branch: string; upstream: string | null; remoteGone?: boolean; worktree?: WorktreeInfo | null }): Promise { settlePending(); return new Promise((resolve) => { - state = { kind: "branchDelete", title: "Delete branch", branch: opts.branch, upstream: opts.upstream, remoteGone: opts.remoteGone ?? false, force: false, deleteRemote: false, resolve }; + state = { kind: "branchDelete", title: "Delete branch", branch: opts.branch, upstream: opts.upstream, remoteGone: opts.remoteGone ?? false, force: false, deleteRemote: false, worktree: opts.worktree ?? null, removeWorktree: false, resolve }; }); }, setBranchDeleteForce(v: boolean) { if (state.kind === "branchDelete") state = { ...state, force: v }; }, setBranchDeleteRemote(v: boolean) { if (state.kind === "branchDelete") state = { ...state, deleteRemote: v }; }, + setBranchDeleteWorktree(v: boolean) { if (state.kind === "branchDelete") state = { ...state, removeWorktree: v }; }, resolveBranchDelete(confirmed: boolean) { if (state.kind === "branchDelete") { - const { force, deleteRemote } = state; - state.resolve({ confirmed, force, deleteRemote }); + const { force, deleteRemote, worktree, removeWorktree } = state; + // Defense in depth: keyboard handlers or a future caller cannot bypass + // the explicit opt-in or the clean/linked-worktree safety gate. + if (confirmed && worktree && (!removeWorktree || worktreeRemovalBlocker(worktree))) return; + state.resolve({ confirmed, force, deleteRemote, removeWorktree }); state = { kind: "none" }; } }, diff --git a/src/lib/dialogs.worktree.test.ts b/src/lib/dialogs.worktree.test.ts new file mode 100644 index 0000000..75bcd61 --- /dev/null +++ b/src/lib/dialogs.worktree.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { dialogs } from "./dialogs.svelte"; +import type { WorktreeInfo } from "./types"; + +function cleanLinkedWorktree(): WorktreeInfo { + return { + path: "/tmp/git-it-linked-worktree", + head: "a".repeat(40), + branch: "feature", + isMain: false, + isCurrent: false, + detached: false, + bare: false, + locked: false, + lockedReason: null, + prunable: false, + prunableReason: null, + status: { + head: { sha: "a".repeat(40), branch: "feature", detached: false }, + staged: 0, + unstaged: 0, + untracked: 0, + conflicted: 0, + operation: null, + }, + }; +} + +describe("linked-worktree branch deletion", () => { + it("cannot confirm until removal is explicitly selected", async () => { + const result = dialogs.confirmBranchDelete({ + branch: "feature", + upstream: null, + worktree: cleanLinkedWorktree(), + }); + + expect(dialogs.state.kind).toBe("branchDelete"); + if (dialogs.state.kind !== "branchDelete") throw new Error("branch dialog did not open"); + expect(dialogs.state.removeWorktree).toBe(false); + + dialogs.resolveBranchDelete(true); + expect(dialogs.state.kind).toBe("branchDelete"); + + dialogs.setBranchDeleteWorktree(true); + dialogs.resolveBranchDelete(true); + + await expect(result).resolves.toEqual({ + confirmed: true, + force: false, + deleteRemote: false, + removeWorktree: true, + }); + expect(dialogs.state.kind).toBe("none"); + }); +}); diff --git a/src/lib/gitActions.partialFailure.test.ts b/src/lib/gitActions.partialFailure.test.ts new file mode 100644 index 0000000..c070c9b --- /dev/null +++ b/src/lib/gitActions.partialFailure.test.ts @@ -0,0 +1,358 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { api } from "./api"; +import { dialogs } from "./dialogs.svelte"; +import { + gitActions, + loadMoreGraph, + refreshActiveRepo, + refreshRefs, + reloadGraph, +} from "./gitActions"; +import { SAMPLE_GRAPH } from "./graph/sample"; +import { appState } from "./store.svelte"; +import type { GraphCommit, Ref, RepoStatus } from "./types"; + +const repoStatus: RepoStatus = { + head: { sha: SAMPLE_GRAPH[0].sha, branch: "main", detached: false }, + staged: 0, + unstaged: 0, + untracked: 0, + conflicted: 0, + operation: null, +}; + +const mainRef: Ref = { + name: "main", + kind: "local", + target_sha: SAMPLE_GRAPH[0].sha, + upstream: null, + ahead: 0, + behind: 0, +}; + +function graphWithoutFeatureDecoration(): GraphCommit[] { + return SAMPLE_GRAPH.map((commit) => ({ + ...commit, + refs: commit.refs.filter((ref) => ref.name !== "feature/graph-view"), + })); +} + +function mockRefreshApis(refreshedGraph: GraphCommit[]) { + vi.spyOn(api, "loadGraph").mockResolvedValue(refreshedGraph); + vi.spyOn(api, "repoStatus").mockResolvedValue(repoStatus); + vi.spyOn(api, "workingChanges").mockResolvedValue([]); + vi.spyOn(api, "listRefs").mockResolvedValue([mainRef]); + vi.spyOn(api, "remotes").mockResolvedValue([]); + vi.spyOn(api, "listWorktrees").mockResolvedValue([]); +} + +beforeEach(() => { + vi.stubGlobal("window", { __TAURI_INTERNALS__: {} }); + appState.repo = "/tmp/git-it-partial-delete-test"; + appState.setRepoLoading(false); + appState.setGraphCommits(SAMPLE_GRAPH); + appState.setGraphHasMore(true); + appState.setRefsDetailed([ + mainRef, + { + name: "feature/graph-view", + kind: "local", + target_sha: SAMPLE_GRAPH[2].sha, + upstream: null, + ahead: 0, + behind: 0, + }, + ]); + appState.setCurrent(SAMPLE_GRAPH[2].sha); + appState.selected = new Set([SAMPLE_GRAPH[2].sha]); + appState.setNewDate(SAMPLE_GRAPH[2].sha, new Date("2020-01-01T00:00:00Z")); +}); + +afterEach(() => { + dialogs.resolveAlert(); + appState.repo = ""; + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("partial branch-delete refresh", () => { + it("removes stale graph decorations without discarding queued edits", async () => { + const refreshedGraph = graphWithoutFeatureDecoration(); + mockRefreshApis(refreshedGraph); + vi.spyOn(api, "deleteBranch").mockRejectedValue( + new Error("Deleted local branch, but remote delete failed"), + ); + + const ok = await gitActions.deleteBranch( + "feature/graph-view", + false, + true, + "origin", + "feature/graph-view", + ); + + expect(ok).toBe(false); + expect( + appState.graphCommits.some((commit) => + commit.refs.some((ref) => ref.name === "feature/graph-view"), + ), + ).toBe(false); + expect(appState.refsByKind.local.some((ref) => ref.name === "feature/graph-view")).toBe( + false, + ); + expect(appState.currentSha).toBe(SAMPLE_GRAPH[2].sha); + expect(appState.selected.has(SAMPLE_GRAPH[2].sha)).toBe(true); + expect(appState.newDates.has(SAMPLE_GRAPH[2].sha)).toBe(true); + }); + + it("discards an in-flight stale page before refreshing the failed operation", async () => { + let resolvePage: (commits: GraphCommit[]) => void = () => {}; + const page = new Promise((resolve) => { + resolvePage = resolve; + }); + let resolveRefresh: (commits: GraphCommit[]) => void = () => {}; + const refresh = new Promise((resolve) => { + resolveRefresh = resolve; + }); + const refreshedGraph = graphWithoutFeatureDecoration(); + const loadGraph = vi + .spyOn(api, "loadGraph") + .mockImplementationOnce(() => page) + .mockImplementationOnce(() => refresh); + vi.spyOn(api, "repoStatus").mockResolvedValue(repoStatus); + vi.spyOn(api, "workingChanges").mockResolvedValue([]); + vi.spyOn(api, "listRefs").mockResolvedValue([mainRef]); + vi.spyOn(api, "remotes").mockResolvedValue([]); + vi.spyOn(api, "listWorktrees").mockResolvedValue([]); + vi.spyOn(api, "deleteBranch").mockRejectedValue( + new Error("Deleted local branch, but remote delete failed"), + ); + + const loading = loadMoreGraph(); + await vi.waitFor(() => expect(loadGraph).toHaveBeenCalledTimes(1)); + const deleting = gitActions.deleteBranch( + "feature/graph-view", + false, + true, + "origin", + "feature/graph-view", + ); + resolvePage([ + { + ...SAMPLE_GRAPH[2], + sha: "stale-page-commit", + refs: [{ name: "feature/graph-view", kind: "local", is_head: false }], + }, + ]); + + await loading; + await vi.waitFor(() => expect(loadGraph).toHaveBeenCalledTimes(2)); + + // The stale page must be rejected as soon as it resolves, not merely + // overwritten later when the post-failure snapshot finishes. + expect(appState.graphCommits.some((commit) => commit.sha === "stale-page-commit")).toBe(false); + + resolveRefresh(refreshedGraph); + await deleting; + + expect(loadGraph).toHaveBeenCalledTimes(2); + expect(appState.graphCommits).toEqual(refreshedGraph); + expect(appState.graphCommits.some((commit) => commit.sha === "stale-page-commit")).toBe(false); + }); + + it("invalidates a stale page when an ordinary reload replaces the same-length graph", async () => { + let resolvePage: (commits: GraphCommit[]) => void = () => {}; + const page = new Promise((resolve) => { + resolvePage = resolve; + }); + let resolveReload: (commits: GraphCommit[]) => void = () => {}; + const reload = new Promise((resolve) => { + resolveReload = resolve; + }); + const refreshedGraph = graphWithoutFeatureDecoration(); + const loadGraph = vi + .spyOn(api, "loadGraph") + .mockImplementationOnce(() => page) + .mockImplementationOnce(() => reload); + vi.spyOn(api, "repoStatus").mockResolvedValue(repoStatus); + vi.spyOn(api, "workingChanges").mockResolvedValue([]); + vi.spyOn(api, "listRefs").mockResolvedValue([mainRef]); + vi.spyOn(api, "remotes").mockResolvedValue([]); + vi.spyOn(api, "listWorktrees").mockResolvedValue([]); + + const loading = loadMoreGraph(); + await vi.waitFor(() => expect(loadGraph).toHaveBeenCalledTimes(1)); + const reloading = reloadGraph(); + await vi.waitFor(() => expect(loadGraph).toHaveBeenCalledTimes(2)); + resolveReload(refreshedGraph); + await reloading; + + resolvePage([ + { + ...SAMPLE_GRAPH[2], + sha: "stale-page-after-reload", + refs: [{ name: "feature/graph-view", kind: "local", is_head: false }], + }, + ]); + await loading; + + expect(appState.graphCommits).toEqual(refreshedGraph); + expect(appState.graphCommits.some((commit) => commit.sha === "stale-page-after-reload")).toBe( + false, + ); + }); + + it("queues a watcher refresh that arrives while another preserving refresh is active", async () => { + let resolveFirstRefresh: (commits: GraphCommit[]) => void = () => {}; + const firstRefresh = new Promise((resolve) => { + resolveFirstRefresh = resolve; + }); + let resolveQueuedRefresh: (commits: GraphCommit[]) => void = () => {}; + const queuedRefresh = new Promise((resolve) => { + resolveQueuedRefresh = resolve; + }); + const refreshedGraph = graphWithoutFeatureDecoration(); + const loadGraph = vi + .spyOn(api, "loadGraph") + .mockImplementationOnce(() => firstRefresh) + .mockImplementationOnce(() => queuedRefresh); + vi.spyOn(api, "repoStatus").mockResolvedValue(repoStatus); + vi.spyOn(api, "workingChanges").mockResolvedValue([]); + vi.spyOn(api, "listRefs").mockResolvedValue([mainRef]); + vi.spyOn(api, "remotes").mockResolvedValue([]); + vi.spyOn(api, "listWorktrees").mockResolvedValue([]); + + refreshActiveRepo(); + await vi.waitFor(() => expect(loadGraph).toHaveBeenCalledTimes(1)); + refreshActiveRepo(); + // Let the second debounced request observe the still-active first refresh. + await new Promise((resolve) => setTimeout(resolve, 160)); + expect(loadGraph).toHaveBeenCalledTimes(1); + + resolveFirstRefresh(SAMPLE_GRAPH); + await vi.waitFor(() => expect(loadGraph).toHaveBeenCalledTimes(2)); + resolveQueuedRefresh(refreshedGraph); + + await vi.waitFor(() => expect(appState.graphCommits).toEqual(refreshedGraph)); + }); + + it("queues a watcher refresh that arrives during pagination", async () => { + let resolvePage: (commits: GraphCommit[]) => void = () => {}; + const page = new Promise((resolve) => { + resolvePage = resolve; + }); + let resolveQueuedRefresh: (commits: GraphCommit[]) => void = () => {}; + const queuedRefresh = new Promise((resolve) => { + resolveQueuedRefresh = resolve; + }); + const refreshedGraph = graphWithoutFeatureDecoration(); + const loadGraph = vi + .spyOn(api, "loadGraph") + .mockImplementationOnce(() => page) + .mockImplementationOnce(() => queuedRefresh); + vi.spyOn(api, "repoStatus").mockResolvedValue(repoStatus); + vi.spyOn(api, "workingChanges").mockResolvedValue([]); + vi.spyOn(api, "listRefs").mockResolvedValue([mainRef]); + vi.spyOn(api, "remotes").mockResolvedValue([]); + vi.spyOn(api, "listWorktrees").mockResolvedValue([]); + + const loading = loadMoreGraph(); + await vi.waitFor(() => expect(loadGraph).toHaveBeenCalledTimes(1)); + refreshActiveRepo(); + // Let the debounced watcher request observe the active page load. + await new Promise((resolve) => setTimeout(resolve, 160)); + expect(loadGraph).toHaveBeenCalledTimes(1); + + resolvePage([]); + await loading; + await vi.waitFor(() => expect(loadGraph).toHaveBeenCalledTimes(2)); + resolveQueuedRefresh(refreshedGraph); + + await vi.waitFor(() => expect(appState.graphCommits).toEqual(refreshedGraph)); + }); + + it("gives a full reload priority over a later preserving refresh", async () => { + let resolveReload: (commits: GraphCommit[]) => void = () => {}; + const reload = new Promise((resolve) => { + resolveReload = resolve; + }); + const refreshedGraph = graphWithoutFeatureDecoration(); + const loadGraph = vi + .spyOn(api, "loadGraph") + .mockImplementationOnce(() => reload) + .mockRejectedValueOnce(new Error("queued preserving refresh failed")); + vi.spyOn(api, "repoStatus").mockResolvedValue(repoStatus); + vi.spyOn(api, "workingChanges").mockResolvedValue([]); + vi.spyOn(api, "listRefs").mockResolvedValue([mainRef]); + vi.spyOn(api, "remotes").mockResolvedValue([]); + vi.spyOn(api, "listWorktrees").mockResolvedValue([]); + vi.spyOn(console, "warn").mockImplementation(() => {}); + appState.setRepoLoading(true); + + const reloading = reloadGraph(); + await vi.waitFor(() => expect(loadGraph).toHaveBeenCalledTimes(1)); + refreshActiveRepo(); + // A preserving load must not start early and invalidate the full reload. + await new Promise((resolve) => setTimeout(resolve, 160)); + expect(loadGraph).toHaveBeenCalledTimes(1); + + resolveReload(refreshedGraph); + await reloading; + + await vi.waitFor(() => expect(loadGraph).toHaveBeenCalledTimes(2)); + expect(appState.graphCommits).toEqual(refreshedGraph); + expect(appState.repoLoading).toBe(false); + }); + + it("queues a post-failure snapshot behind an older live refresh", async () => { + let resolveLiveRefresh: (commits: GraphCommit[]) => void = () => {}; + const liveRefresh = new Promise((resolve) => { + resolveLiveRefresh = resolve; + }); + const refreshedGraph = graphWithoutFeatureDecoration(); + const loadGraph = vi + .spyOn(api, "loadGraph") + .mockImplementationOnce(() => liveRefresh) + .mockResolvedValueOnce(refreshedGraph); + vi.spyOn(api, "repoStatus").mockResolvedValue(repoStatus); + vi.spyOn(api, "workingChanges").mockResolvedValue([]); + vi.spyOn(api, "listRefs").mockResolvedValue([mainRef]); + vi.spyOn(api, "remotes").mockResolvedValue([]); + vi.spyOn(api, "listWorktrees").mockResolvedValue([]); + vi.spyOn(api, "deleteBranch").mockRejectedValue( + new Error("Deleted local branch, but remote delete failed"), + ); + + refreshActiveRepo(); + await vi.waitFor(() => expect(loadGraph).toHaveBeenCalledTimes(1)); + const deleting = gitActions.deleteBranch( + "feature/graph-view", + false, + true, + "origin", + "feature/graph-view", + ); + resolveLiveRefresh(SAMPLE_GRAPH); + + await deleting; + + expect(loadGraph).toHaveBeenCalledTimes(2); + expect(appState.graphCommits).toEqual(refreshedGraph); + }); + + it("falls back to graph refs when the detailed inventory refresh fails", async () => { + appState.setRefsDetailed([mainRef]); + expect(appState.refsByKind.local.some((ref) => ref.name === "feature/graph-view")).toBe( + false, + ); + vi.spyOn(api, "listRefs").mockRejectedValue(new Error("list refs failed")); + vi.spyOn(api, "remotes").mockResolvedValue([]); + vi.spyOn(api, "listWorktrees").mockResolvedValue([]); + vi.spyOn(console, "warn").mockImplementation(() => {}); + + await refreshRefs(); + + expect(appState.refsDetailed).toEqual([]); + expect(appState.refsByKind.local.some((ref) => ref.name === "feature/graph-view")).toBe(true); + }); +}); diff --git a/src/lib/gitActions.ts b/src/lib/gitActions.ts index ed08b04..70daae0 100644 --- a/src/lib/gitActions.ts +++ b/src/lib/gitActions.ts @@ -16,6 +16,16 @@ function isTauri(): boolean { const PAGE = 150; +// Coordinate preserving graph refreshes with incremental page loads. A failed +// operation may still have changed refs (for example, local delete succeeded but +// remote delete failed), so its refresh must supersede any page fetched from the +// pre-operation graph rather than letting stale decorations append afterward. +let graphRefreshGeneration = 0; +let graphPageLoadPromise: Promise | null = null; +let preservingGraphRefreshPromise: Promise | null = null; +let graphReloadPromise: Promise | null = null; +let preservingGraphRefreshQueued = false; + export async function refreshStatus(): Promise { if (!isTauri() || !appState.repo) { appState.setRepoStatus(null); @@ -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. @@ -303,12 +435,14 @@ async function run(label: string, fn: () => Promise): Promise return true; } catch (e) { // A partial success (e.g. the local branch was deleted but its remote delete failed) - // should still update the sidebar — but use the NON-destructive refreshRefs(), not - // reloadGraph(). reloadGraph() resets graphCommits via setGraphCommits(), which clears - // the user's queued commit-time edits (newDates), selection, and currentSha; an - // unrelated failed op (rejected checkout, branch-already-exists, fetch error) must not - // silently discard those. refreshRefs() only re-reads the ref/remote lists. - try { await refreshRefs(); } catch (err) { console.warn("[gte] refs refresh after failed op", err); } + // should update every graph decoration as well as the sidebar. Use the + // preserving refresh: reloadGraph()/setGraphCommits() would clear queued + // commit-time edits, selection, and currentSha after an unrelated failure. + try { + await refreshGraphPreservingState(true); + } catch (err) { + console.warn("[gte] preserving graph refresh after failed op", err); + } appState.status = `${label} failed: ${firstLine(e)}`; // The status bar alone is too easy to miss for a discrete action the user just took // (user-reported "no feedback for if something went wrong") — surface it as a dialog. @@ -575,9 +709,10 @@ export const gitActions = { deleteRemote = false, remote?: string, remoteBranch?: string, + worktreePath?: string, ) => run(`Delete branch ${name}`, () => - api.deleteBranch(appState.repo, name, force, deleteRemote, remote, remoteBranch), + api.deleteBranch(appState.repo, name, force, deleteRemote, remote, remoteBranch, worktreePath), ), deleteRemoteBranch: (remote: string, branch: string) => run(`Delete remote branch ${remote}/${branch}`, () => diff --git a/src/lib/store.svelte.ts b/src/lib/store.svelte.ts index bf8b542..d622228 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; @@ -1585,9 +1600,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 +1629,7 @@ function makeState() { refsByKind.local.find((r) => r.isHead)?.name ?? null, ); const currentUpstreamRef = $derived( - currentBranchName + currentBranchName && refsDetailedRepo === repo ? (refsDetailed.find((r) => r.kind === "local" && r.name === currentBranchName) ?? null) : null, ); @@ -1637,6 +1659,7 @@ function makeState() { currentSha = null; selected = new Set(); newDates = new Map(); + refsDetailedRepo = null; if (v === "") { // Closing the LAST repo: clear the displayed data so the empty state // shows — nothing will reload it. @@ -1644,6 +1667,7 @@ function makeState() { workingChanges = []; workingChangesRev = 0; refsDetailed = []; + worktrees = []; remotesState = []; graphCommits = []; commits = []; @@ -2210,10 +2234,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; 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..0713604 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -98,6 +98,21 @@ export type RepoStatus = { operation: string | null; }; +export type WorktreeInfo = { + path: string; + head: string | null; + branch: string | null; + isMain: boolean; + isCurrent: boolean; + detached: boolean; + bare: boolean; + locked: boolean; + lockedReason: string | null; + prunable: boolean; + prunableReason: string | null; + status: RepoStatus | null; +}; + export type OpOutcome = { conflicted: boolean; files: string[]; diff --git a/src/lib/worktrees.test.ts b/src/lib/worktrees.test.ts new file mode 100644 index 0000000..70b75cc --- /dev/null +++ b/src/lib/worktrees.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import type { WorktreeInfo } from "./types"; +import { + worktreeForBranch, + worktreeIsDirty, + worktreeRemovalBlocker, + worktreeStatusLabel, +} from "./worktrees"; + +function worktree(overrides: Partial = {}): WorktreeInfo { + return { + path: "/tmp/repo-worktree", + head: "a".repeat(40), + branch: "feature", + isMain: false, + isCurrent: false, + detached: false, + bare: false, + locked: false, + lockedReason: null, + prunable: false, + prunableReason: null, + status: { + head: { sha: "a".repeat(40), branch: "feature", detached: false }, + staged: 0, + unstaged: 0, + untracked: 0, + conflicted: 0, + operation: null, + }, + ...overrides, + }; +} + +describe("worktree helpers", () => { + it("maps a local branch to its worktree", () => { + const feature = worktree(); + expect(worktreeForBranch([feature], "feature")).toBe(feature); + expect(worktreeForBranch([feature], "main")).toBeUndefined(); + }); + + it("treats file changes, conflicts, and in-progress operations as dirty", () => { + expect(worktreeIsDirty(worktree())).toBe(false); + expect(worktreeIsDirty(worktree({ status: { ...worktree().status!, untracked: 1 } }))).toBe(true); + expect(worktreeIsDirty(worktree({ status: { ...worktree().status!, operation: "rebase" } }))).toBe(true); + 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("main worktree"); + expect(worktreeRemovalBlocker(worktree({ locked: true, lockedReason: "in use" }))).toContain("in use"); + expect(worktreeRemovalBlocker(worktree({ prunable: true }))).toContain("stale or missing"); + expect(worktreeRemovalBlocker(worktree({ status: null }))).toContain("could not verify"); + expect( + worktreeRemovalBlocker(worktree({ status: { ...worktree().status!, unstaged: 1 } })), + ).toContain("uncommitted changes"); + 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!, staged: 1 } }))).toBe("Modified"); + expect(worktreeStatusLabel(worktree())).toBeNull(); + }); +}); diff --git a/src/lib/worktrees.ts b/src/lib/worktrees.ts new file mode 100644 index 0000000..fa18fc6 --- /dev/null +++ b/src/lib/worktrees.ts @@ -0,0 +1,61 @@ +import type { WorktreeInfo } from "./types"; + +export function worktreeForBranch( + worktrees: readonly WorktreeInfo[], + branch: string, +): WorktreeInfo | undefined { + return worktrees.find((worktree) => worktree.branch === branch); +} + +export function worktreeIsDirty(worktree: WorktreeInfo): boolean { + const status = worktree.status; + return !!status && ( + status.staged > 0 || + status.unstaged > 0 || + status.untracked > 0 || + status.conflicted > 0 || + status.operation !== null + ); +} + +/** A non-null result means the app must not offer removal. The backend repeats + * every check immediately before executing; this is the explanatory UI gate. */ +export function worktreeRemovalBlocker(worktree: WorktreeInfo): string | null { + if (worktree.isMain) { + return "Git's main worktree cannot be removed. Check out a different branch there before deleting this branch."; + } + if (worktree.isCurrent) { + return "This is the worktree currently open in Git It. Open the repository from another worktree before removing it."; + } + if (worktree.locked) { + return worktree.lockedReason + ? `This worktree is locked: ${worktree.lockedReason}` + : "This worktree is locked. Unlock it before removing it."; + } + if (worktree.prunable) { + return worktree.prunableReason + ? `This worktree is stale or missing: ${worktree.prunableReason}` + : "This worktree is stale or missing. Prune its Git metadata before deleting the branch."; + } + if (worktree.bare || worktree.detached || worktree.branch === null) { + return "This worktree no longer has the branch checked out. Refresh the repository and try again."; + } + if (worktree.status === null) { + return "Git It could not verify that this worktree is clean, so it will not remove it."; + } + if (worktreeIsDirty(worktree)) { + return "This worktree has uncommitted changes or an operation in progress. Commit, stash, or discard them before removing it."; + } + return null; +} + +export function worktreeStatusLabel(worktree: WorktreeInfo): string | null { + if (worktree.isCurrent) return "Current"; + if (worktree.locked) return "Locked"; + if (worktree.prunable) return "Missing"; + if (worktree.status === null && !worktree.bare) return "Unavailable"; + if (worktreeIsDirty(worktree)) return "Modified"; + if (worktree.detached) return "Detached"; + if (worktree.bare) return "Bare"; + return null; +}