diff --git a/Cargo.lock b/Cargo.lock index b1479f0b..bfb59b95 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1116,6 +1116,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" dependencies = [ "memchr", + "regex-automata", "serde_core", ] @@ -2043,6 +2044,46 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" +[[package]] +name = "defmt" +version = "0.3.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0963443817029b2024136fc4dd07a5107eb8f977eaf18fcd1fdeb11306b64ad" +dependencies = [ + "defmt 1.1.1", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.20", +] + [[package]] name = "delegate" version = "0.13.5" @@ -2526,6 +2567,18 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "faster-hex" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04839bdf9d8c10f66806fad16b852fc72aab80873aebc3cb69d85b4fa41543ed" +dependencies = [ + "autocfg", + "defmt 0.3.100", + "heapless 0.8.0", + "serde", +] + [[package]] name = "fastrand" version = "1.9.0" @@ -3111,6 +3164,217 @@ dependencies = [ "winapi", ] +[[package]] +name = "gix-actor" +version = "0.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e046e9a929e8e1f40f9a34f736408c4c079f9cf004429f3e718d270e847be96" +dependencies = [ + "bstr", + "gix-date", + "gix-error", +] + +[[package]] +name = "gix-date" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8beceb16a77fb222923592864b94beb4ad4c2b175a7055c2cac6815ee0370a" +dependencies = [ + "bstr", + "gix-error", + "itoa", + "jiff", +] + +[[package]] +name = "gix-discover" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4476ba055ec5fbb05f59dd98380b596428da060beac5f9dccd7374302ad1fbfb" +dependencies = [ + "bstr", + "dunce", + "gix-error", + "gix-fs", + "gix-path", + "gix-ref", + "gix-sec", +] + +[[package]] +name = "gix-error" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4a5c4cd326ddfe7f046ad9eed05461c59af6ea98b681648bac73954317addd" +dependencies = [ + "bstr", +] + +[[package]] +name = "gix-features" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb3a430dbc02d6c5e30a9f92083459593495ea22a476e837d248b22d800eed80" +dependencies = [ + "gix-path", + "gix-trace", + "gix-utils", + "libc", + "prodash", + "walkdir", +] + +[[package]] +name = "gix-fs" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0105ab1cef4542d092c27766d2daf1548fcbad7e460dc2208642c120bb19b3ad" +dependencies = [ + "bstr", + "gix-error", + "gix-features", + "gix-path", + "gix-utils", +] + +[[package]] +name = "gix-hash" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a22b6febaaee8546eb0c9dd21ecdd93aca269a6cb3897e1250f0f60933c0174" +dependencies = [ + "faster-hex", + "gix-error", + "gix-features", + "sha1dc", +] + +[[package]] +name = "gix-hashtable" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c55bff2f32f6f5a95bdd43e5e5e01b0656250cffbc48eda19db0afa3d73adf2f" +dependencies = [ + "gix-hash", + "hashbrown 0.17.1", + "parking_lot", +] + +[[package]] +name = "gix-lock" +version = "25.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd5ddfd0cc476fef744ac544783b96a9ff5d49f8de541e6e9d6d6002aa90720" +dependencies = [ + "gix-error", + "gix-tempfile", + "gix-utils", +] + +[[package]] +name = "gix-object" +version = "0.65.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a72909eeeed845721a143ef3dbeb4d6cd5df79c839fe7eefc138cb4f982eba3e" +dependencies = [ + "bstr", + "gix-actor", + "gix-date", + "gix-error", + "gix-features", + "gix-hash", + "gix-hashtable", + "gix-utils", + "gix-validate", + "itoa", + "smallvec", +] + +[[package]] +name = "gix-path" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd051ac60c5dbce2228daabf25b6a76408ac732d416c678cfa0a4914d13f60b8" +dependencies = [ + "bstr", + "gix-error", + "gix-trace", + "gix-validate", +] + +[[package]] +name = "gix-ref" +version = "0.68.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c90ceeec67de03b737c30b1b69116ca56fec803edde022f3d25181f0f597083d" +dependencies = [ + "gix-actor", + "gix-error", + "gix-features", + "gix-fs", + "gix-hash", + "gix-lock", + "gix-object", + "gix-path", + "gix-tempfile", + "gix-utils", + "gix-validate", + "memmap2", +] + +[[package]] +name = "gix-sec" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8eb983a830ba586b61f7005deaf44609e1a4186cbf13a0e8668154ca910461" +dependencies = [ + "bitflags 2.13.1", + "gix-path", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "gix-tempfile" +version = "25.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dd7dd8df3345933caddd6ce67821c9eec17192564f1011cf0bd0b8cb0d07ffb" +dependencies = [ + "gix-fs", + "libc", + "parking_lot", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "gix-trace" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df45b2cf6052d9e52681130c17d67505b023e8933ad974f94a2be0b0d24f636a" + +[[package]] +name = "gix-utils" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70525766f53426ca4cd2ea4d58b0b31488dfedb416b3254e2efdd9dc184cc880" +dependencies = [ + "fastrand 2.5.0", + "getrandom 0.4.3", + "unicode-normalization", +] + +[[package]] +name = "gix-validate" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30574f81fc41b2ca3582ece2bdf8696bdfe9c170aa7e8bcafb81199c86b1ceea" +dependencies = [ + "bstr", + "gix-error", +] + [[package]] name = "gl_generator" version = "0.14.0" @@ -3323,7 +3587,7 @@ dependencies = [ "gpui_macros", "gpui_shared_string", "gpui_util", - "heapless", + "heapless 0.9.3", "http_client", "image", "inventory", @@ -3877,6 +4141,16 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + [[package]] name = "heapless" version = "0.9.3" @@ -4562,6 +4836,60 @@ dependencies = [ "rustc-hash 2.1.3", ] +[[package]] +name = "jiff" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb" +dependencies = [ + "defmt 1.1.1", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link 0.2.1", +] + +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt 1.1.1", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "jni" version = "0.21.1" @@ -6822,6 +7150,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "prodash" +version = "31.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "962200e2d7d551451297d9fdce85138374019ada198e30ea9ede38034e27604c" +dependencies = [ + "parking_lot", +] + [[package]] name = "profiling" version = "1.0.18" @@ -8312,6 +8649,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" +[[package]] +name = "sha1dc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5b0ebf6537e3e8db8bb7d21ecb6d7399738c953c757945c6b95e46c4e95df7b" + [[package]] name = "sha2" version = "0.10.9" @@ -8783,7 +9126,7 @@ name = "sum_tree" version = "0.1.0" source = "git+https://github.com/l0ng-ai/zed?branch=tty7#5c390b9359e39f645ed6819e0be4bf3157e8ab3f" dependencies = [ - "heapless", + "heapless 0.9.3", "log", "rayon", "tracing", @@ -9999,6 +10342,7 @@ dependencies = [ "base64 0.22.1", "core-foundation 0.10.0", "getrandom 0.3.4", + "gix-discover", "hex", "ignore", "libc", @@ -10110,6 +10454,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-properties" version = "0.1.4" diff --git a/crates/tty7-core/Cargo.toml b/crates/tty7-core/Cargo.toml index 738c7939..d0723765 100644 --- a/crates/tty7-core/Cargo.toml +++ b/crates/tty7-core/Cargo.toml @@ -42,6 +42,14 @@ uuid = { version = "1", features = ["v4", "serde"] } # even though only the GUI computes one today. sha2 = "0.11" +# Finding the repository a pane's cwd is in (`core::git::head`) without a +# `git` process: the sidebar asks it of every pane, and a spawn that fails — +# `/usr/bin/git` on macOS is a shim that dies whenever the selected Xcode is +# mid-switch — used to read as "not a repository" and ungroup the tab. Only the +# discovery crate: counts, diffs and every write still go through the `git` +# CLI, so what the sidebar says stays the same answer the diff views give. +gix-discover = { version = "0.56", features = ["sha1"] } + # The gitignore matcher chain (`core::gitignore`) the file tree dims entries # with — the same crate ripgrep uses. Lives here rather than in the GUI because # the remote server has to answer "is this path ignored?" with the identical diff --git a/crates/tty7-core/src/core/git/head.rs b/crates/tty7-core/src/core/git/head.rs new file mode 100644 index 00000000..bd2b603e --- /dev/null +++ b/crates/tty7-core/src/core/git/head.rs @@ -0,0 +1,179 @@ +//! Which repository a directory is in, read off the disk. +//! +//! The sidebar asks this of every pane, and asking `git` meant three processes +//! a pane — each of which can fail for reasons that have nothing to do with the +//! directory. On macOS `/usr/bin/git` is a shim that hands off to whichever +//! Xcode is selected, and dies while that selection is mid-switch; every probe +//! in that window used to come back "not a repository" and pull its tab out of +//! its group. Nothing here spawns anything: the answer is a handful of small +//! files, which is all `git rev-parse` reads for it too. +//! +//! Only the *where*: root, home and the branch name. The line counts beside +//! the branch still come from `git diff --numstat`, the same CLI the diff views +//! read, so the two never disagree about a number. + +use std::path::{Path, PathBuf}; + +/// Where a directory's repository is, as far as the files on disk say. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct RepoHead { + /// The working tree the directory is in — `git rev-parse --show-toplevel`. + pub root: PathBuf, + /// The main working tree a linked one belongs to; `root` otherwise. + pub home: PathBuf, + /// The checked-out branch, when HEAD names one plainly. `None` for + /// anything `git` should spell instead — a detached HEAD, whose short sha + /// `git` lengthens until it is unambiguous, and a reftable repository, + /// whose HEAD file is a placeholder. + pub branch: Option, +} + +/// The repository `cwd` is in, or `None` when it is in none. +/// +/// A bare repository counts as none, and so does a `.git` directory itself — +/// `git rev-parse --show-toplevel` refuses both, and there is no working tree +/// for a tab to be grouped under. So does a directory that does not exist. +pub fn read_head(cwd: &Path) -> Option { + let options = gix_discover::upwards::Options::default().apply_environment(); + let (path, _trust) = gix_discover::upwards_opts(cwd, options).ok()?; + let (git_dir, work_dir) = path.into_repository_and_work_tree_directories(); + let work_dir = work_dir?; + // `git` answers with the physical path, and the root is the key the diff + // views file their own `rev-parse` answer under: a pane that reached its + // repository through a symlink has to land on the same spelling. + let root = std::fs::canonicalize(&work_dir).ok()?; + let git_dir = std::fs::canonicalize(&git_dir).ok()?; + // Standing in the git dir itself: discovery walks up to the tree it + // belongs to, but `git` refuses the question there, and so does this. + if std::fs::canonicalize(cwd).is_ok_and(|cwd| cwd.starts_with(&git_dir)) { + return None; + } + let home = home_of(&root, &git_dir); + let branch = std::fs::read_to_string(git_dir.join("HEAD")) + .ok() + .and_then(|head| branch_of(&head)); + Some(RepoHead { root, home, branch }) +} + +/// The main working tree `root` belongs to, given its git directory. +/// +/// A linked worktree's private git dir names the shared one in `commondir`; +/// that shared dir is the main tree's `.git`, whose parent is the home. A +/// shared dir with another name is a bare repository with worktrees hung off +/// it, and the repository itself is the home — the same rule `repo_home` +/// applies to what `rev-parse --git-common-dir` prints. +fn home_of(root: &Path, git_dir: &Path) -> PathBuf { + let Ok(common) = std::fs::read_to_string(git_dir.join("commondir")) else { + return root.to_path_buf(); + }; + let Ok(common) = std::fs::canonicalize(git_dir.join(common.trim_end_matches(['\n', '\r']))) + else { + return root.to_path_buf(); + }; + if common == git_dir { + return root.to_path_buf(); + } + match (common.file_name(), common.parent()) { + (Some(name), Some(parent)) if name == ".git" => parent.to_path_buf(), + _ => common, + } +} + +/// The branch a HEAD file names, spelled the way `git symbolic-ref --short` +/// would, or `None` where that is not a plain read. +fn branch_of(head: &str) -> Option { + let name = head + .trim_end_matches(['\n', '\r']) + .strip_prefix("ref: refs/heads/")?; + // Reftable keeps HEAD only for older tools to trip over. + (!name.is_empty() && name != ".invalid").then(|| name.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::git::test_support::PINS; + + fn git(cwd: &Path, args: &[&str]) -> Option { + let mut full = PINS.to_vec(); + full.extend_from_slice(args); + let out = crate::core::git::git_output(cwd, &full).ok()?; + out.success() + .then(|| String::from_utf8_lossy(&out.stdout).trim().to_string()) + } + + #[test] + fn head_files_spell_branches_like_git() { + assert_eq!(branch_of("ref: refs/heads/main\n").as_deref(), Some("main")); + assert_eq!( + branch_of("ref: refs/heads/feat/x\r\n").as_deref(), + Some("feat/x") + ); + assert_eq!( + branch_of("0123456789abcdef0123456789abcdef01234567\n"), + None + ); + assert_eq!(branch_of("ref: refs/heads/.invalid\n"), None); + assert_eq!(branch_of("ref: refs/remotes/origin/main\n"), None); + } + + /// The whole point: every answer here is the one `git` gives, for a main + /// tree, a subdirectory, a linked worktree and a plain directory. + #[test] + fn agrees_with_git_about_repos_and_worktrees() { + let dir = tempfile::tempdir().unwrap(); + let main = dir.path().join("main"); + std::fs::create_dir(&main).unwrap(); + if git(&main, &["init", "--quiet"]).is_none() { + return; // no git on this machine + } + git(&main, &["symbolic-ref", "HEAD", "refs/heads/trunk"]).unwrap(); + git(&main, &["commit", "--quiet", "--allow-empty", "-m", "one"]).unwrap(); + let sub = main.join("src/deep"); + std::fs::create_dir_all(&sub).unwrap(); + let linked = dir.path().join("linked"); + git( + &main, + &[ + "worktree", + "add", + "--quiet", + "-b", + "feat/x", + linked.to_str().unwrap(), + ], + ) + .unwrap(); + + let canon = |p: &Path| std::fs::canonicalize(p).unwrap(); + for (cwd, root, home, branch) in [ + (&main, &main, &main, "trunk"), + (&sub, &main, &main, "trunk"), + (&linked, &linked, &main, "feat/x"), + ] { + let got = read_head(cwd).unwrap_or_else(|| panic!("{} is a repo", cwd.display())); + assert_eq!(got.root, canon(root), "root of {}", cwd.display()); + assert_eq!(got.home, canon(home), "home of {}", cwd.display()); + assert_eq!(got.branch.as_deref(), Some(branch)); + assert_eq!( + got.root, + PathBuf::from(git(cwd, &["rev-parse", "--show-toplevel"]).unwrap()), + "and git names the same root" + ); + } + + git(&main, &["checkout", "--quiet", "--detach"]).unwrap(); + assert_eq!( + read_head(&main).unwrap().branch, + None, + "git spells a detached HEAD" + ); + + assert_eq!( + read_head(&main.join(".git")), + None, + "a git dir has no working tree" + ); + assert_eq!(read_head(&dir.path().join("gone")), None); + } +} diff --git a/crates/tty7-core/src/core/git/mod.rs b/crates/tty7-core/src/core/git/mod.rs index 07314e7e..7371c85a 100644 --- a/crates/tty7-core/src/core/git/mod.rs +++ b/crates/tty7-core/src/core/git/mod.rs @@ -10,6 +10,7 @@ //! questions for a remote workspace that `LocalHost` answers for this machine, //! and the conformance suite holds the two to the same behaviour. pub mod diff; +pub mod head; pub mod log; pub mod ops; pub mod status; @@ -35,9 +36,59 @@ pub struct RepoSnapshot { pub counts: Option<(u32, u32)>, } -pub fn probe(host: &dyn Host, cwd: &Path) -> Option { - let paths = git( - host, +/// What asking a directory "which repository are you in" came back with. +/// +/// Two ways of not getting a snapshot, and they must not be confused. "Not a +/// repository" is an answer about the directory and holds until it moves; a +/// probe that *failed* — `git` would not start, the Xcode shim behind +/// `/usr/bin/git` had no developer directory to hand off to, a remote link +/// dropped mid-call — says nothing about it at all. Filing the second as the +/// first is what used to pull a tab sitting in a perfectly good repository out +/// of its sidebar group, for good: an idle pane never asks again. +#[derive(Clone, PartialEq, Eq, Debug)] +pub enum RepoProbe { + Repo(RepoSnapshot), + NotARepo, + Failed, +} + +/// Exit status of a `git` that ran and refused: `fatal: not a git repository`, +/// and the handful of other fatals (a `safe.directory` refusal among them) +/// that mean this directory is not one git will work in. +const GIT_FATAL: i32 = 128; + +pub fn probe_repo(host: &dyn Host, cwd: &Path) -> RepoProbe { + let (root, home, branch) = match host.repo_head(cwd) { + Some(None) => return RepoProbe::NotARepo, + Some(Some(head)) => ( + crate::core::path_spelling::spelling_on_buf(host.id(), &head.root), + crate::core::path_spelling::spelling_on_buf(host.id(), &head.home), + head.branch, + ), + None => match locate(host, cwd) { + Ok((root, home)) => (root, home, None), + Err(outcome) => return outcome, + }, + }; + // A branch the files could not spell plainly — a detached HEAD above all — + // is `git`'s to name, so it matches what the diff views print for it. + // `rev-parse` has already said this is a repository, so a branch that + // cannot be read is the probe failing, not the directory changing its mind. + let Some(branch) = branch.or_else(|| branch_name(host, cwd)) else { + return RepoProbe::Failed; + }; + RepoProbe::Repo(RepoSnapshot { + home, + root, + branch, + counts: diff_numstat(host, cwd), + }) +} + +/// Root and home of `cwd`'s repository, asked of `git` — for a host that +/// cannot read them in-process ([`Host::repo_head`]). +fn locate(host: &dyn Host, cwd: &Path) -> Result<(PathBuf, PathBuf), RepoProbe> { + let out = match host.git( cwd, &[ "rev-parse", @@ -46,17 +97,42 @@ pub fn probe(host: &dyn Host, cwd: &Path) -> Option { "--git-dir", "--git-common-dir", ], - )?; + ) { + Ok(out) => out, + // A directory that is gone — a worktree removed under a pane still + // sitting in it — is not a repository, and never will be again. + Err(_) + if host + .stat(cwd) + .is_err_and(|e| e.kind() == io::ErrorKind::NotFound) => + { + return Err(RepoProbe::NotARepo); + } + Err(_) => return Err(RepoProbe::Failed), + }; + if !out.success() { + return Err(match out.status { + Some(GIT_FATAL) => RepoProbe::NotARepo, + _ => RepoProbe::Failed, + }); + } + let Ok(paths) = String::from_utf8(out.stdout) else { + return Err(RepoProbe::Failed); + }; let mut lines = paths.lines().map(|l| l.trim_end_matches(['\n', '\r'])); - let root = git_path(host, lines.next()?); + let Some(root) = lines.next().map(|l| git_path(host, l)) else { + return Err(RepoProbe::Failed); + }; let home = repo_home(host, &root, lines.next(), lines.next()); - let branch = branch_name(host, cwd)?; - Some(RepoSnapshot { - home, - root, - branch, - counts: diff_numstat(host, cwd), - }) + Ok((root, home)) +} + +/// [`probe_repo`] for a caller that only wants the snapshot. +pub fn probe(host: &dyn Host, cwd: &Path) -> Option { + match probe_repo(host, cwd) { + RepoProbe::Repo(snap) => Some(snap), + RepoProbe::NotARepo | RepoProbe::Failed => None, + } } /// A path `git` just printed, in the spelling the rest of tty7 keys by. @@ -498,6 +574,28 @@ mod tests { ); } + #[test] + fn a_plain_or_vanished_directory_is_not_a_repo() { + let host = h(); + let dir = tempfile::tempdir().unwrap(); + let plain = dir.path().join("plain"); + std::fs::create_dir(&plain).unwrap(); + // A temp directory can sit under a repository of its own on some + // machine; git has nothing to say about this test's premise there. + if host + .git(&plain, &["rev-parse", "--git-dir"]) + .is_ok_and(|o| o.success()) + { + return; + } + assert_eq!(probe_repo(&*host, &plain), RepoProbe::NotARepo); + assert_eq!( + probe_repo(&*host, &dir.path().join("gone")), + RepoProbe::NotARepo, + "a removed worktree is not a failed probe" + ); + } + #[test] fn line_splitter_rejoins_across_chunks() { let mut split = LineSplitter::default(); diff --git a/crates/tty7-core/src/host/local.rs b/crates/tty7-core/src/host/local.rs index 7a21324e..c4b53cc7 100644 --- a/crates/tty7-core/src/host/local.rs +++ b/crates/tty7-core/src/host/local.rs @@ -316,6 +316,11 @@ impl Host for LocalHost { git::git_output_with_env(cwd, args, &no_prompt_env()) } + fn repo_head(&self, cwd: &Path) -> Option> { + guard_off_ui(); + Some(git::head::read_head(cwd)) + } + fn git_lines( &self, cwd: &Path, diff --git a/crates/tty7-core/src/host/mod.rs b/crates/tty7-core/src/host/mod.rs index 4bf81ecc..029c0799 100644 --- a/crates/tty7-core/src/host/mod.rs +++ b/crates/tty7-core/src/host/mod.rs @@ -332,6 +332,18 @@ pub trait Host: Send + Sync + 'static { fn git(&self, cwd: &Path, args: &[&str]) -> io::Result; + /// The repository `cwd` is in, answered without running `git` — or + /// `None` when this host cannot, and [`crate::core::git::probe_repo`] + /// asks `git` instead. + /// + /// Only this machine answers. A remote host could read the same files on + /// its side, but asking it to would be a request its server has never + /// heard of, and one of those takes the whole link down; the `git` it + /// runs over there has no Xcode shim to trip on anyway. + fn repo_head(&self, _cwd: &Path) -> Option> { + None + } + /// `git`, but with an explicit ceiling on how long to wait for the far side. /// /// Network verbs (`fetch`/`pull`/`push`) run for as long as the network diff --git a/crates/tty7-core/src/host/remote.rs b/crates/tty7-core/src/host/remote.rs index 864576df..9826b2f6 100644 --- a/crates/tty7-core/src/host/remote.rs +++ b/crates/tty7-core/src/host/remote.rs @@ -1228,6 +1228,56 @@ mod tests { assert!(!inv.shells[0].args_are_tty7_defaults); } + /// A remote host has no in-process answer, so the repo probe asks the + /// far side's `git` — and has to tell a refusal about the directory from + /// a `git` that never ran, or a tab loses its group to a flaky link. + #[test] + fn a_remote_repo_probe_tells_not_a_repo_from_a_failure() { + use crate::core::git::{RepoProbe, probe_repo}; + let out = |status: i32, stdout: &str| { + ControlReply::Ok(ReplyOk::Output(Output { + status: Some(status), + stdout: stdout.as_bytes().to_vec(), + stderr: Vec::new(), + })) + }; + let (host, _seen) = host_with_peer('/', move |req| { + let reply = match req { + ControlRequest::Git { cwd, args } => match (cwd.as_str(), args[0].as_str()) { + ("/repo", "rev-parse") => out(0, "/repo\n/repo/.git\n/repo/.git\n"), + ("/repo", "symbolic-ref") => out(0, "main\n"), + ("/repo", _) => out(0, "3\t1\tsrc/main.rs\n"), + ("/plain", _) => out(128, ""), + ("/shim", _) => out(1, ""), + ("/gone", _) => { + ControlReply::Err(WireError::new(WireErrorKind::NotFound, "no cwd")) + } + _ => ControlReply::Err(WireError::new(WireErrorKind::ConnectionReset, "")), + }, + ControlRequest::Stat { path } if path == "/gone" => { + ControlReply::Err(WireError::new(WireErrorKind::NotFound, "gone")) + } + ControlRequest::Stat { .. } => ControlReply::Ok(ReplyOk::Meta(meta())), + other => panic!("unexpected request {other:?}"), + }; + Some((reply, vec![])) + }); + let h: &dyn Host = host.as_ref(); + + let RepoProbe::Repo(snap) = probe_repo(h, Path::new("/repo")) else { + panic!("/repo is a repository"); + }; + assert_eq!( + (snap.root.as_path(), snap.branch.as_str()), + (Path::new("/repo"), "main") + ); + assert_eq!(snap.counts, Some((3, 1))); + assert_eq!(probe_repo(h, Path::new("/plain")), RepoProbe::NotARepo); + assert_eq!(probe_repo(h, Path::new("/gone")), RepoProbe::NotARepo); + assert_eq!(probe_repo(h, Path::new("/shim")), RepoProbe::Failed); + assert_eq!(probe_repo(h, Path::new("/flaky")), RepoProbe::Failed); + } + #[test] fn agent_sessions_come_from_the_peer() { use crate::core::agent_history::PastSession; diff --git a/src/terminal/git_status.rs b/src/terminal/git_status.rs index a8fa0250..24b75e80 100644 --- a/src/terminal/git_status.rs +++ b/src/terminal/git_status.rs @@ -2,7 +2,7 @@ use std::borrow::Cow; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; -pub use crate::core::git::{GitStatus, RepoSnapshot, probe}; +pub use crate::core::git::{GitStatus, RepoProbe, RepoSnapshot, probe_repo}; use crate::ui::host_ops::{ByHost, HostId, InFlight}; /// The spelling a directory is keyed by in here. @@ -213,6 +213,22 @@ impl GitStatusCache { } rerun } + + /// Land a probe that got no answer ([`RepoProbe::Failed`]). + /// + /// Only the bookkeeping moves: the probe is no longer in flight, and the + /// throttle counts from now so a retry waits its turn. What the cache + /// knew about `cwd` stays exactly as it was — a repository it had found + /// is still that repository, and a cwd it had never answered stays + /// unanswered, which is what lets a tab keep the group it remembers + /// instead of dropping into Ungrouped on a `git` that did not run. + pub fn fail_probe(&mut self, host: HostId, cwd: &Path) -> bool { + let cwd = key(host, cwd); + let rerun = !self.probes.finish(&(host, cwd.to_path_buf())); + let throttle = self.throttle_key(host, &cwd).to_path_buf(); + self.last_probe.insert(host, throttle, Instant::now()); + rerun + } } #[cfg(test)] @@ -403,6 +419,34 @@ mod tests { assert!(cache.status_for(L, b).is_some()); } + #[test] + fn a_failed_probe_leaves_what_was_known() { + let mut cache = GitStatusCache::default(); + let (repo, unseen) = (Path::new("/repo/a"), Path::new("/repo/b")); + cache.finish_probe(L, repo, Some(snap("/repo", "main", Some((1, 0))))); + + assert!(cache.begin_probe(L, repo)); + assert!(!cache.fail_probe(L, repo)); + assert_eq!( + cache.known_repo_for(L, repo), + Some(Some(PathBuf::from("/repo"))), + "a git that did not run says nothing about the repository" + ); + assert_eq!(cache.status_for(L, repo).unwrap().branch, "main"); + + assert!(cache.begin_probe(L, unseen)); + cache.fail_probe(L, unseen); + assert_eq!( + cache.known_repo_for(L, unseen), + None, + "still unknown, not \"not a repository\"" + ); + assert!( + !cache.begin_probe_throttled(L, unseen, Duration::from_secs(60)), + "the retry waits out the throttle" + ); + } + #[test] fn known_repo_for_is_three_valued() { let mut cache = GitStatusCache::default(); diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 72c9a9d7..febd1f23 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -661,6 +661,13 @@ const INTEGRATION_NOTICE_TIMEOUT: std::time::Duration = std::time::Duration::fro const OPPORTUNISTIC_GIT_GAP: std::time::Duration = std::time::Duration::from_millis(1500); +/// How long a pane whose repository is still unanswered waits before asking +/// again. Only a probe that failed leaves it that way, and whatever broke it +/// (a remote link mid-reconnect, a `git` that would not start) is gone in +/// seconds, not milliseconds — nor worth a request every poll tick while it +/// lasts. +const GIT_RETRY_GAP: std::time::Duration = std::time::Duration::from_secs(10); + /// How long a title the program set has to stand before the tab adopts it. /// /// Long enough that a command which is over almost as soon as it started never @@ -705,6 +712,9 @@ const MAX_HISTORY_BYTES: u64 = 4 << 20; enum GitRefresh { Edge, Opportunistic, + /// Nothing happened; the cache just has no answer for this pane's cwd + /// yet, because the last probe failed. Throttled by [`GIT_RETRY_GAP`]. + Retry, } fn known_pty_shim(fg: &str) -> Option<&'static str> { @@ -2157,6 +2167,20 @@ impl TerminalView { self.git_status_cwd.as_deref() } + /// This pane sits somewhere the repo cache cannot place yet — neither a + /// repository nor "not one". A probe in flight looks the same, and the + /// throttle turns the retry away; the case this exists for is a probe that + /// failed, which leaves the answer open instead of recording a wrong one, + /// and would otherwise wait for the cwd to change or a command to finish + /// before anyone asked again. + fn git_repo_unanswered(&self, cx: &App) -> bool { + let Some(cwd) = self.git_status_cwd.as_deref() else { + return false; + }; + cx.try_global::() + .is_none_or(|cache| cache.known_repo_for(self.host_id, cwd).is_none()) + } + /// Plant the cwd the git-status poll would have found. For tests that /// need a pane to look like it is sitting somewhere known — a real poll /// needs a live shell reporting a directory, which a quiet test pane has @@ -4011,6 +4035,8 @@ impl TerminalView { self.refresh_git_status(cwd_now, GitRefresh::Edge, cx); } else if tool_activity { self.refresh_git_status(cwd_now, GitRefresh::Opportunistic, cx); + } else if self.git_repo_unanswered(cx) { + self.refresh_git_status(cwd_now, GitRefresh::Retry, cx); } self.follow_history_scope(cx); @@ -4219,6 +4245,7 @@ impl TerminalView { GitRefresh::Opportunistic => { cache.begin_probe_throttled(id, &cwd, OPPORTUNISTIC_GIT_GAP) } + GitRefresh::Retry => cache.begin_probe_throttled(id, &cwd, GIT_RETRY_GAP), }); if !claimed { return; @@ -4228,10 +4255,13 @@ impl TerminalView { crate::ui::host_ops::HostOps::run_detached( host, cx, - move |h| crate::terminal::git_status::probe(h, &probe_cwd), + move |h| crate::terminal::git_status::probe_repo(h, &probe_cwd), move |cx, result| { - let rerun = cx.update_global::(|cache, _| { - cache.finish_probe(id, &cwd, result) + use crate::terminal::git_status::RepoProbe; + let rerun = cx.update_global::(|cache, _| match result { + RepoProbe::Repo(snap) => cache.finish_probe(id, &cwd, Some(snap)), + RepoProbe::NotARepo => cache.finish_probe(id, &cwd, None), + RepoProbe::Failed => cache.fail_probe(id, &cwd), }); if rerun { let _ = pane.update(cx, |view, cx| {