mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-10-03 16:01:59 +00:00
fix(sidebar): stop ungrouping tabs when a git probe fails (#1011)
A tab's auto group comes from probing its cwd for a repository. Any failure of that probe - git failing to spawn, the macOS /usr/bin/git shim dying while Xcode is mid-switch, a remote link dropping - came back as "not a repository", overwrote the tab's remembered group, and was never re-checked while the pane sat idle. Tabs in perfectly good repositories dropped into Ungrouped and stayed there. - Read root, home and branch in-process on this machine with gix-discover instead of three git processes. Only the line counts still come from `git diff --numstat`, so they keep matching the diff views; a detached HEAD is still named by git for the same reason. - Remote hosts keep asking their own git (a new control request would force a dialect bump), but the probe now tells "not a repository" (exit 128, or the directory is gone) apart from a failure. - A failed probe leaves the cache as it was, so a tab keeps its remembered group, and an unanswered cwd is retried every 10s.
This commit is contained in:
Generated
+355
-2
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
/// 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<RepoHead> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<RepoSnapshot> {
|
||||
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<RepoSnapshot> {
|
||||
"--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<RepoSnapshot> {
|
||||
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();
|
||||
|
||||
@@ -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<Option<git::head::RepoHead>> {
|
||||
guard_off_ui();
|
||||
Some(git::head::read_head(cwd))
|
||||
}
|
||||
|
||||
fn git_lines(
|
||||
&self,
|
||||
cwd: &Path,
|
||||
|
||||
@@ -332,6 +332,18 @@ pub trait Host: Send + Sync + 'static {
|
||||
|
||||
fn git(&self, cwd: &Path, args: &[&str]) -> io::Result<Output>;
|
||||
|
||||
/// 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<Option<crate::core::git::head::RepoHead>> {
|
||||
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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
+33
-3
@@ -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::<crate::terminal::git_status::GitStatusCache>()
|
||||
.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::<GitStatusCache, _>(|cache, _| {
|
||||
cache.finish_probe(id, &cwd, result)
|
||||
use crate::terminal::git_status::RepoProbe;
|
||||
let rerun = cx.update_global::<GitStatusCache, _>(|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| {
|
||||
|
||||
Reference in New Issue
Block a user