From eb139b48ab2f782667c38ea12703e34836f6805b Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 23 Aug 2026 19:17:13 +0800 Subject: [PATCH] test(scm): check each panel group against the plumbing it stands for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `in_group` decides which of Staged, Changes, Untracked and Merge a file is drawn under, and every test of it was against records this file wrote — which checks the mapping against our reading of the porcelain rather than against git. The two rows a mistake there produces are a Stage button on a file with nothing staged, and a Discard button on a file whose only change is already in the index. So the same tree described twice: once by `probe_status`, once by the plumbing each group mirrors — `diff --cached --name-only`, `diff --name-only`, `ls-files --others --exclude-standard`. They agree, including the two cases worth arranging on purpose: a file staged and then edited again belongs to Staged *and* Changes, and a deletion that was never staged belongs to Changes. Two ways this could have passed without proving anything, both closed: every group is asserted non-empty, so agreement between two empty sets does not count; and the run was checked to reach its assertions rather than take the no-git early return. --- src/ui/scm/panel.rs | 110 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/src/ui/scm/panel.rs b/src/ui/scm/panel.rs index 94c13196..e977d2d5 100644 --- a/src/ui/scm/panel.rs +++ b/src/ui/scm/panel.rs @@ -2206,6 +2206,116 @@ mod tests { .collect() } + /// The groups the panel draws are the ones git itself would name. + /// + /// `in_group` is read off `XY` by way of `is_staged` / `is_unstaged` / + /// `is_untracked`, and every test of it so far has been against records + /// this file wrote. That checks the mapping against our reading of the + /// porcelain, not against git — and the two rows a mistake here produces + /// are a Stage button on a file with nothing staged and a Discard button + /// on a file whose only change is already in the index. + /// + /// So the same tree, described twice: once by `probe_status` and once by + /// the plumbing commands whose answers those groups are supposed to + /// mirror. + #[test] + fn every_group_matches_the_plumbing_command_it_stands_for() { + use std::collections::BTreeSet; + + let host = tty7_core::host::local::LocalHost::new(); + let repo = std::env::temp_dir().join(format!("tty7-groups-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&repo); + std::fs::create_dir_all(&repo).unwrap(); + + let git = |args: &[&str]| -> Option { + let out = host.git(&repo, args).ok()?; + out.success() + .then(|| String::from_utf8_lossy(&out.stdout).into_owned()) + }; + let must = |args: &[&str]| { + assert!(git(args).is_some(), "git {args:?} failed"); + }; + if git(&["init", "--quiet"]).is_none() { + let _ = std::fs::remove_dir_all(&repo); + return; // no git on this machine + } + must(&["config", "user.email", "t@example.invalid"]); + must(&["config", "user.name", "t"]); + must(&["symbolic-ref", "HEAD", "refs/heads/main"]); + + // A base commit, then one file in each state a working tree can hold. + for name in [ + "staged-edit.txt", + "worktree-edit.txt", + "both.txt", + "gone.txt", + ] { + std::fs::write(repo.join(name), "base\n").unwrap(); + } + must(&["add", "-A"]); + must(&["commit", "--quiet", "-m", "base"]); + + std::fs::write(repo.join("staged-edit.txt"), "staged\n").unwrap(); + must(&["add", "staged-edit.txt"]); + std::fs::write(repo.join("worktree-edit.txt"), "worktree\n").unwrap(); + std::fs::write(repo.join("both.txt"), "staged\n").unwrap(); + must(&["add", "both.txt"]); + std::fs::write(repo.join("both.txt"), "and then more\n").unwrap(); + std::fs::remove_file(repo.join("gone.txt")).unwrap(); + std::fs::write(repo.join("brand-new.txt"), "new\n").unwrap(); + + let status = match tty7_core::core::git::status::probe_status(&*host, &repo) { + tty7_core::core::git::status::StatusProbe::Status(s) => *s, + other => panic!("the scratch repository has no status: {other:?}"), + }; + let ours = |group: ScmGroup| -> BTreeSet { + status + .entries + .iter() + .filter(|e| in_group(e, group)) + .map(|e| e.path.as_str().to_string()) + .collect() + }; + let theirs = |args: &[&str]| -> BTreeSet { + git(args) + .unwrap_or_default() + .lines() + .filter(|l| !l.is_empty()) + .map(str::to_string) + .collect() + }; + + assert_eq!( + ours(ScmGroup::Staged), + theirs(&["diff", "--cached", "--name-only"]), + "the Staged group and `git diff --cached` disagree" + ); + assert_eq!( + ours(ScmGroup::Changes), + theirs(&["diff", "--name-only"]), + "the Changes group and `git diff` disagree" + ); + assert_eq!( + ours(ScmGroup::Untracked), + theirs(&["ls-files", "--others", "--exclude-standard"]), + "the Untracked group and `git ls-files --others` disagree" + ); + // The tree was arranged to put something in each of them, so an empty + // set on both sides cannot pass this off as agreement. + for (group, name) in [ + (ScmGroup::Staged, "Staged"), + (ScmGroup::Changes, "Changes"), + (ScmGroup::Untracked, "Untracked"), + ] { + assert!( + !ours(group).is_empty(), + "{name} was empty, so it proved nothing" + ); + } + + let _ = std::fs::remove_dir_all(&repo); + } + #[test] fn a_file_staged_and_edited_again_lands_in_both_groups() { let e = entry(