Line 2 of a session card spelled out its blocked work ("1 uncommitted
file") and its command ("claude"). Both now render as marks: the
breakdown as diff notation, and the command's leading program as its
tool's icon in the web client.
The probe carries the line delta of the tracked changes — staged and
unstaged together, against HEAD — so `git add` never makes the counter
drop. Untracked files stay a file count; counting their lines means
reading every one off disk. A tracked change with no line delta falls
back to `~N` rather than claiming `+0 -0`.
The TUI keeps its command as text: a terminal cannot be assumed to have
a font carrying tool glyphs.
319 lines
12 KiB
Rust
319 lines
12 KiB
Rust
//! The one git probe for what a worktree holds that would be lost (#79).
|
||
//!
|
||
//! [`in_the_way`] is the single predicate behind both paths: the kill refusal
|
||
//! (#79) and the natural-exit classifier (#82). It lives here rather than in
|
||
//! `worktree.rs` so that neither can answer the question differently.
|
||
|
||
use std::path::Path;
|
||
|
||
use maestro_protocol::InTheWay;
|
||
|
||
#[derive(Debug, thiserror::Error)]
|
||
pub enum ProbeError {
|
||
#[error("failed to spawn git: {0}")]
|
||
Spawn(#[from] std::io::Error),
|
||
#[error("git exited with code {code}: {stderr}")]
|
||
NonZero { code: i32, stderr: String },
|
||
}
|
||
|
||
/// What a kill refusal would report about the worktree at `path`, right now.
|
||
///
|
||
/// Ignored files never count (`--ignored=no`) — every Rust worktree has a
|
||
/// `target/`, and counting those would put every tree permanently in the way.
|
||
/// A directory that no longer exists reports nothing in the way: there is
|
||
/// nothing left to lose.
|
||
pub fn in_the_way(path: &Path) -> Result<InTheWay, ProbeError> {
|
||
let mut breakdown = InTheWay::default();
|
||
// `symlink_metadata` rather than `exists`: a broken symlink is gone for
|
||
// our purposes, and following it would probe the wrong tree.
|
||
if std::fs::symlink_metadata(path).is_err() {
|
||
return Ok(breakdown);
|
||
}
|
||
let status = run_git(
|
||
path,
|
||
[
|
||
"status",
|
||
"--porcelain",
|
||
"--ignored=no",
|
||
"--untracked-files=normal",
|
||
],
|
||
)?;
|
||
for line in String::from_utf8_lossy(&status.stdout).lines() {
|
||
if line.starts_with("??") {
|
||
breakdown.untracked_files += 1;
|
||
} else if !line.is_empty() {
|
||
breakdown.uncommitted_files += 1;
|
||
}
|
||
}
|
||
// The line delta behind the sidebar's `+N −M` (docs/SIDEBAR.md). Diffing
|
||
// the working tree against HEAD covers staged and unstaged changes in one
|
||
// pass, so a `git add` never makes the counter drop.
|
||
let numstat = run_git(path, ["diff", "--numstat", "HEAD"]);
|
||
if let Ok(numstat) = numstat {
|
||
for line in String::from_utf8_lossy(&numstat.stdout).lines() {
|
||
let mut cols = line.split('\t');
|
||
// A binary file reports `-` for both columns; it has no lines to
|
||
// add to either total.
|
||
let added: usize = cols.next().and_then(|c| c.parse().ok()).unwrap_or(0);
|
||
let removed: usize = cols.next().and_then(|c| c.parse().ok()).unwrap_or(0);
|
||
breakdown.insertions += added;
|
||
breakdown.deletions += removed;
|
||
}
|
||
}
|
||
// A repo with no commits yet has no HEAD to diff against, and the counts
|
||
// stay zero rather than failing the whole probe — the file counts above
|
||
// already reported the work.
|
||
|
||
// Resolved inside the worktree so a detached HEAD still has a starting
|
||
// point; asking the base repo for `refs/heads/<branch>` fails there.
|
||
let head = run_git(path, ["rev-parse", "--abbrev-ref", "HEAD"])?;
|
||
let branch = String::from_utf8_lossy(&head.stdout).trim().to_string();
|
||
let tip: &str = if branch == "HEAD" { "HEAD" } else { &branch };
|
||
// Commits reachable from the tip but from no remote-tracking ref. A branch
|
||
// pushed anywhere counts as pushed; there is no "which remote" question to
|
||
// get wrong.
|
||
let unpushed = run_git(path, ["rev-list", "--count", tip, "--not", "--remotes"])?;
|
||
breakdown.unpushed_commits = String::from_utf8_lossy(&unpushed.stdout)
|
||
.trim()
|
||
.parse()
|
||
.unwrap_or(0);
|
||
Ok(breakdown)
|
||
}
|
||
|
||
fn run_git<I, S>(dir: &Path, args: I) -> Result<std::process::Output, ProbeError>
|
||
where
|
||
I: IntoIterator<Item = S>,
|
||
S: AsRef<std::ffi::OsStr>,
|
||
{
|
||
let mut cmd = std::process::Command::new("git");
|
||
cmd.arg("-C").arg(dir).args(args);
|
||
let out = cmd.output()?;
|
||
if !out.status.success() {
|
||
return Err(ProbeError::NonZero {
|
||
code: out.status.code().unwrap_or(-1),
|
||
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
|
||
});
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use std::path::PathBuf;
|
||
use std::process::Command;
|
||
|
||
struct Repo {
|
||
root: tempfile::TempDir,
|
||
repo: PathBuf,
|
||
}
|
||
|
||
fn git(dir: &Path, args: &[&str]) {
|
||
let out = Command::new("git")
|
||
.arg("-C")
|
||
.arg(dir)
|
||
.args(args)
|
||
.output()
|
||
.expect("git spawn");
|
||
assert!(
|
||
out.status.success(),
|
||
"git {args:?} failed: {}",
|
||
String::from_utf8_lossy(&out.stderr)
|
||
);
|
||
}
|
||
|
||
/// A repo with a remote, one commit on `main`, and a linked worktree for
|
||
/// branch `feature` at `<repo>/worktrees/feature`.
|
||
fn repo_with_worktree() -> Repo {
|
||
let root = tempfile::tempdir().expect("tempdir");
|
||
let repo = root.path().join("repo");
|
||
let remote = root.path().join("remote.git");
|
||
std::fs::create_dir_all(&repo).unwrap();
|
||
std::fs::create_dir_all(&remote).unwrap();
|
||
git(&repo, &["init", "-q", "-b", "main"]);
|
||
git(&repo, &["config", "user.email", "t@t"]);
|
||
git(&repo, &["config", "user.name", "T"]);
|
||
std::fs::write(repo.join("README"), b"hello\n").unwrap();
|
||
git(&repo, &["add", "."]);
|
||
git(&repo, &["commit", "-q", "-m", "init"]);
|
||
git(&remote, &["init", "-q", "--bare"]);
|
||
git(&repo, &["remote", "add", "origin", &path_str(&remote)]);
|
||
git(&repo, &["push", "-q", "-u", "origin", "main"]);
|
||
git(
|
||
&repo,
|
||
&[
|
||
"worktree",
|
||
"add",
|
||
"-q",
|
||
"-b",
|
||
"feature",
|
||
"worktrees/feature",
|
||
],
|
||
);
|
||
Repo { root, repo }
|
||
}
|
||
|
||
fn path_str(path: &Path) -> String {
|
||
path.to_string_lossy().into_owned()
|
||
}
|
||
|
||
impl Repo {
|
||
fn wt(&self) -> PathBuf {
|
||
self.repo.join("worktrees").join("feature")
|
||
}
|
||
|
||
fn commit_in_wt(&self, msg: &str) {
|
||
std::fs::write(self.wt().join("README"), msg.as_bytes()).unwrap();
|
||
git(&self.wt(), &["add", "-A"]);
|
||
git(&self.wt(), &["commit", "-qm", msg]);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_clean_pushed_worktree_has_nothing_in_the_way() {
|
||
let r = repo_with_worktree();
|
||
git(&r.wt(), &["push", "-q", "-u", "origin", "feature"]);
|
||
let breakdown = in_the_way(&r.wt()).expect("probe");
|
||
assert_eq!(breakdown, InTheWay::default());
|
||
assert!(breakdown.is_clear());
|
||
}
|
||
|
||
#[test]
|
||
fn uncommitted_untracked_and_unpushed_are_counted_separately() {
|
||
let r = repo_with_worktree();
|
||
std::fs::write(r.wt().join("README"), b"edited\n").unwrap();
|
||
std::fs::write(r.wt().join("scratch.txt"), b"untracked\n").unwrap();
|
||
|
||
let dirty = in_the_way(&r.wt()).expect("probe");
|
||
assert_eq!(dirty.uncommitted_files, 1);
|
||
assert_eq!(dirty.untracked_files, 1);
|
||
assert_eq!(dirty.unpushed_commits, 0);
|
||
assert!(!dirty.is_clear());
|
||
|
||
// Committing clears exactly one dimension and creates another.
|
||
git(&r.wt(), &["add", "README"]);
|
||
git(&r.wt(), &["commit", "-qm", "wip"]);
|
||
|
||
let breakdown = in_the_way(&r.wt()).expect("probe");
|
||
assert_eq!(breakdown.uncommitted_files, 0);
|
||
assert_eq!(breakdown.untracked_files, 1);
|
||
assert_eq!(breakdown.unpushed_commits, 1);
|
||
}
|
||
|
||
#[test]
|
||
fn tracked_edits_count_as_uncommitted() {
|
||
let r = repo_with_worktree();
|
||
std::fs::write(r.wt().join("README"), b"edited\n").unwrap();
|
||
let breakdown = in_the_way(&r.wt()).expect("probe");
|
||
assert_eq!(breakdown.uncommitted_files, 1);
|
||
assert_eq!(breakdown.untracked_files, 0);
|
||
}
|
||
|
||
#[test]
|
||
fn pushing_clears_unpushed_but_keeps_other_entries() {
|
||
let r = repo_with_worktree();
|
||
std::fs::write(r.wt().join("new.txt"), b"x\n").unwrap();
|
||
r.commit_in_wt("wip");
|
||
|
||
let before = in_the_way(&r.wt()).expect("probe");
|
||
assert_eq!(before.unpushed_commits, 1);
|
||
|
||
git(&r.wt(), &["push", "-q", "-u", "origin", "feature"]);
|
||
// Same stored entry, different tree: the breakdown must move with it.
|
||
let after = in_the_way(&r.wt()).expect("probe");
|
||
assert_eq!(after.unpushed_commits, 0);
|
||
assert_eq!(after.untracked_files, 0);
|
||
|
||
std::fs::write(r.wt().join("loose.txt"), b"x\n").unwrap();
|
||
let dirty = in_the_way(&r.wt()).expect("probe");
|
||
assert_eq!(dirty.untracked_files, 1);
|
||
}
|
||
|
||
/// docs/SIDEBAR.md: the sidebar renders `+N −M`, so the probe carries the
|
||
/// line delta of the tracked changes — staged and unstaged together,
|
||
/// against HEAD.
|
||
#[test]
|
||
fn staged_and_unstaged_lines_both_count() {
|
||
let r = repo_with_worktree();
|
||
std::fs::write(r.wt().join("staged.txt"), b"a\nb\n").unwrap();
|
||
git(&r.wt(), &["add", "staged.txt"]);
|
||
// README loses its one line and gains three, unstaged.
|
||
std::fs::write(r.wt().join("README"), b"x\ny\nz\n").unwrap();
|
||
|
||
let breakdown = in_the_way(&r.wt()).expect("probe");
|
||
assert_eq!(breakdown.insertions, 5, "2 staged + 3 unstaged");
|
||
assert_eq!(breakdown.deletions, 1);
|
||
}
|
||
|
||
/// Untracked files are counted as files and never as lines — reading each
|
||
/// one off disk is the cost the sidebar contract declines to pay.
|
||
#[test]
|
||
fn untracked_files_contribute_no_lines() {
|
||
let r = repo_with_worktree();
|
||
std::fs::write(r.wt().join("scratch.txt"), b"a\nb\nc\n").unwrap();
|
||
let breakdown = in_the_way(&r.wt()).expect("probe");
|
||
assert_eq!(breakdown.untracked_files, 1);
|
||
assert_eq!(breakdown.insertions, 0);
|
||
assert_eq!(breakdown.deletions, 0);
|
||
}
|
||
|
||
/// A binary file's numstat is `-\t-`. It must not poison the totals, and
|
||
/// it must not abort the probe either.
|
||
#[test]
|
||
fn binary_changes_add_no_lines() {
|
||
let r = repo_with_worktree();
|
||
std::fs::write(r.wt().join("blob.bin"), [0u8, 159, 0, 12]).unwrap();
|
||
git(&r.wt(), &["add", "blob.bin"]);
|
||
git(&r.wt(), &["commit", "-qm", "blob"]);
|
||
std::fs::write(r.wt().join("blob.bin"), [0u8, 1, 2, 3, 4]).unwrap();
|
||
|
||
let breakdown = in_the_way(&r.wt()).expect("probe");
|
||
assert_eq!(breakdown.uncommitted_files, 1);
|
||
assert_eq!(breakdown.insertions, 0);
|
||
assert_eq!(breakdown.deletions, 0);
|
||
}
|
||
|
||
/// A line delta alone never decides `is_clear` — the file counts do — so a
|
||
/// mode change with no line delta still blocks a kill.
|
||
#[test]
|
||
fn a_line_less_change_still_blocks() {
|
||
let r = repo_with_worktree();
|
||
let script = r.wt().join("run.sh");
|
||
std::fs::write(&script, b"echo hi\n").unwrap();
|
||
git(&r.wt(), &["add", "run.sh"]);
|
||
git(&r.wt(), &["commit", "-qm", "script"]);
|
||
git(&r.wt(), &["update-index", "--chmod=+x", "run.sh"]);
|
||
|
||
let breakdown = in_the_way(&r.wt()).expect("probe");
|
||
assert_eq!(breakdown.insertions, 0);
|
||
assert_eq!(breakdown.deletions, 0);
|
||
assert!(!breakdown.is_clear());
|
||
}
|
||
|
||
#[test]
|
||
fn ignored_files_never_count() {
|
||
let r = repo_with_worktree();
|
||
std::fs::write(r.wt().join(".gitignore"), b"target/\n").unwrap();
|
||
std::fs::create_dir_all(r.wt().join("target")).unwrap();
|
||
std::fs::write(r.wt().join("target/artifact"), b"x").unwrap();
|
||
|
||
let breakdown = in_the_way(&r.wt()).expect("probe");
|
||
assert_eq!(
|
||
breakdown,
|
||
InTheWay {
|
||
untracked_files: 1,
|
||
..InTheWay::default()
|
||
},
|
||
"the .gitignore itself is untracked; target/ is not"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_missing_directory_reports_nothing_in_the_way() {
|
||
let r = repo_with_worktree();
|
||
let gone = r.root.path().join("nowhere");
|
||
let breakdown = in_the_way(&gone).expect("probe");
|
||
assert!(breakdown.is_clear());
|
||
}
|
||
}
|