diff --git a/crates/tty7-core/src/core/git/mod.rs b/crates/tty7-core/src/core/git/mod.rs index deeca65a..dcbe19e4 100644 --- a/crates/tty7-core/src/core/git/mod.rs +++ b/crates/tty7-core/src/core/git/mod.rs @@ -59,7 +59,7 @@ pub fn probe(host: &dyn Host, cwd: &Path) -> Option { }) } -fn repo_home(root: &Path, git_dir: Option<&str>, common_dir: Option<&str>) -> PathBuf { +pub(crate) fn repo_home(root: &Path, git_dir: Option<&str>, common_dir: Option<&str>) -> PathBuf { let (Some(git_dir), Some(common)) = (git_dir, common_dir) else { return root.to_path_buf(); }; diff --git a/crates/tty7-core/src/core/git/status.rs b/crates/tty7-core/src/core/git/status.rs index 45dd1e2b..2969234b 100644 --- a/crates/tty7-core/src/core/git/status.rs +++ b/crates/tty7-core/src/core/git/status.rs @@ -13,7 +13,10 @@ //! them; the parser is just one producer. use std::collections::HashMap; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; + +use super::RecordSplitter; +use crate::host::{Entry, Host}; /// A path relative to the repository root, always `/`-separated. /// @@ -391,6 +394,32 @@ pub struct StatusIndex { } impl StatusIndex { + /// Fold a whole status into the lookup the file tree renders against. + /// + /// Built once per refresh on a background thread; the render path only ever + /// probes it. Doing it the other way round — asking the status for a path + /// while drawing a row — is a linear scan per visible row. + pub fn build(status: &WorkingTreeStatus) -> StatusIndex { + let mut index = StatusIndex { + root: status.root.clone(), + ..StatusIndex::default() + }; + for entry in &status.entries { + let deco = entry.deco(); + index.insert(entry.path.as_str(), deco); + // A rename's old path is no longer on disk, so no tree row will ask + // for it — but the directory it left did lose a file, and the + // rollup is the only place that can say so. + if let Some(orig) = &entry.orig_path { + index.insert(orig.as_str(), deco); + } + } + if index.files.len() > MAX_DECORATED_FILES { + index.drop_files(); + } + index + } + pub fn file(&self, repo_rel: &str) -> Option { self.files.get(repo_rel).copied() } @@ -425,3 +454,1221 @@ impl StatusIndex { self.files_dropped = true; } } + +/// The one command behind everything above. +/// +/// `-z` is not a preference: without it any path with a space, a quote or a +/// newline comes back C-quoted. It also makes `core.quotePath=false` a no-op +/// here — measured, identical output either way — so that flag is carried only +/// to keep every git invocation in this module shaped the same; the place it +/// actually fixes something is the `diff --git` header. `--untracked-files=all` +/// pays for an extra walk, but a collapsed `dir/` row cannot be staged file by +/// file, which is most of what the panel is for. +const STATUS_ARGS: &[&str] = &[ + "-c", + "core.quotePath=false", + "status", + "--porcelain=v2", + "--branch", + "--show-stash", + "--untracked-files=all", + "-z", +]; + +/// `MERGE_MSG` is a commit message, not a file; anything past this is a +/// runaway and the commit box is better off empty than full of it. +const MAX_PREFILLED_MESSAGE: u64 = 64 * 1024; + +/// Everything one `--porcelain=v2` run can tell us on its own. +/// +/// Split out from [`WorkingTreeStatus`] because the remaining fields — where +/// the repository lives, and what sequencer operation is parked in it — come +/// from the filesystem, not from the parse. Keeping them apart is what lets the +/// header and record parsing be tested without a repository. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct ParsedStatus { + pub head: HeadState, + pub upstream: Option, + /// `(ahead, behind)`. `None` means *unknown*, never *in sync*. + pub ahead_behind: Option<(u32, u32)>, + pub entries: Vec, + pub total_entries: usize, + pub truncated: bool, + pub stash_count: u32, +} + +impl ParsedStatus { + /// Finish the picture with what only a filesystem probe knows. + pub fn into_status( + self, + root: PathBuf, + home: PathBuf, + operation: Option, + prefilled_message: Option, + ) -> WorkingTreeStatus { + WorkingTreeStatus { + root, + home, + head: self.head, + upstream: self.upstream, + ahead_behind: self.ahead_behind, + entries: self.entries, + total_entries: self.total_entries, + truncated: self.truncated, + stash_count: self.stash_count, + operation, + prefilled_message, + } + } +} + +/// Parse the stdout of [`STATUS_ARGS`]. +/// +/// Never fails: git's output is only ever malformed if we asked for the wrong +/// format, and a status that is missing one unreadable row is far better for +/// the panel than no status at all. Records that do not parse are dropped. +pub fn parse_porcelain_v2(stdout: &[u8]) -> ParsedStatus { + let mut parser = Parser::default(); + let mut split = RecordSplitter::new(0); + split.push(stdout, |record| parser.record(record)); + split.finish(|record| parser.record(record)); + parser.finish() +} + +#[derive(Default)] +struct Parser { + oid: Option, + head_name: Option, + upstream: Option, + ahead_behind: Option<(u32, u32)>, + stash_count: u32, + entries: Vec, + total: usize, + /// A `2` record's *old* path is a separate NUL token, so the record after + /// one is data rather than a new record. Parking the half-built entry here + /// is the whole reason a rename does not swallow the row behind it. + pending_rename: Option, +} + +impl Parser { + fn record(&mut self, record: &[u8]) { + if let Some(mut entry) = self.pending_rename.take() { + entry.orig_path = Some(RepoPath::from_bytes(record)); + self.push(entry); + return; + } + match record.first() { + Some(b'#') => self.header(record), + Some(b'1') => self.ordinary(record), + Some(b'2') => self.renamed(record), + Some(b'u') => self.unmerged(record), + Some(b'?') => self.bare(record, EntryKind::Untracked), + // We never pass `--ignored`, so this never arrives today. Handling + // it anyway costs one arm and means adding the flag later is a + // one-line change rather than a silent hole in the parse. + Some(b'!') => self.bare(record, EntryKind::Ignored), + _ => {} + } + } + + fn header(&mut self, record: &[u8]) { + let Ok(text) = std::str::from_utf8(record) else { + return; + }; + let Some(rest) = text.strip_prefix("# ") else { + return; + }; + let (key, value) = rest.split_once(' ').unwrap_or((rest, "")); + match key { + "branch.oid" => self.oid = Some(value.to_string()), + "branch.head" => self.head_name = Some(value.to_string()), + "branch.upstream" => self.upstream = Some(value.to_string()), + "branch.ab" => self.ahead_behind = parse_ab(value), + "stash" => self.stash_count = value.parse().unwrap_or(0), + _ => {} + } + } + + /// `1 ` + fn ordinary(&mut self, record: &[u8]) { + let mut fields = Fields::new(record); + let Some((x, y, submodule)) = fields.prefix() else { + return; + }; + let (Some(index), Some(worktree)) = (ChangeCode::from_byte(x), ChangeCode::from_byte(y)) + else { + return; + }; + if fields.skip(5).is_none() { + return; + } + self.push(StatusEntry { + path: RepoPath::from_bytes(fields.tail()), + orig_path: None, + index, + worktree, + kind: EntryKind::Tracked, + submodule, + rename_score: None, + conflict: None, + }); + } + + /// `2 \0\0` + fn renamed(&mut self, record: &[u8]) { + let mut fields = Fields::new(record); + let Some((x, y, submodule)) = fields.prefix() else { + return; + }; + let (Some(index), Some(worktree)) = (ChangeCode::from_byte(x), ChangeCode::from_byte(y)) + else { + return; + }; + if fields.skip(5).is_none() { + return; + } + let Some(score) = fields.next() else { + return; + }; + self.pending_rename = Some(StatusEntry { + path: RepoPath::from_bytes(fields.tail()), + orig_path: None, + index, + worktree, + kind: EntryKind::Tracked, + submodule, + rename_score: parse_score(score), + conflict: None, + }); + } + + /// `u

` + fn unmerged(&mut self, record: &[u8]) { + let mut fields = Fields::new(record); + let Some((x, y, submodule)) = fields.prefix() else { + return; + }; + if fields.skip(7).is_none() { + return; + } + self.push(StatusEntry { + path: RepoPath::from_bytes(fields.tail()), + orig_path: None, + index: ChangeCode::from_byte(x).unwrap_or(ChangeCode::Unmerged), + worktree: ChangeCode::from_byte(y).unwrap_or(ChangeCode::Unmerged), + kind: EntryKind::Unmerged, + submodule, + rename_score: None, + // `XY` on a `u` record is the stage pair, not a change pair, so a + // conflict git does not name is still a conflict. + conflict: Some(ConflictKind::from_xy(x, y).unwrap_or(ConflictKind::BothModified)), + }); + } + + /// `? ` and `! ` — no fields, just the path. + fn bare(&mut self, record: &[u8], kind: EntryKind) { + let mut fields = Fields::new(record); + if fields.next().is_none() { + return; + } + self.push(StatusEntry { + path: RepoPath::from_bytes(fields.tail()), + orig_path: None, + index: ChangeCode::None, + worktree: ChangeCode::None, + kind, + submodule: None, + rename_score: None, + conflict: None, + }); + } + + /// Past the cap we keep counting but stop keeping, so the panel can say + /// "10000 of 42311" rather than quietly showing a short list. + fn push(&mut self, entry: StatusEntry) { + self.total += 1; + if self.entries.len() < MAX_STATUS_ENTRIES { + self.entries.push(entry); + } + } + + fn finish(mut self) -> ParsedStatus { + // A `2` with nothing behind it means the output was cut short. The file + // is still real, so keep the row and lose only the old path. + if let Some(entry) = self.pending_rename.take() { + self.push(entry); + } + ParsedStatus { + head: head_state(self.oid, self.head_name), + upstream: self.upstream, + ahead_behind: self.ahead_behind, + truncated: self.total > self.entries.len(), + total_entries: self.total, + entries: self.entries, + stash_count: self.stash_count, + } + } +} + +/// Walks the fixed head of a record one space-delimited field at a time, then +/// hands back the rest verbatim — the path is always last and may contain +/// spaces, so it must never be split. +struct Fields<'a> { + rest: &'a [u8], +} + +impl<'a> Fields<'a> { + fn new(record: &'a [u8]) -> Fields<'a> { + Fields { rest: record } + } + + fn next(&mut self) -> Option<&'a [u8]> { + let at = self.rest.iter().position(|b| *b == b' ')?; + let (field, after) = self.rest.split_at(at); + self.rest = &after[1..]; + Some(field) + } + + fn skip(&mut self, n: usize) -> Option<()> { + for _ in 0..n { + self.next()?; + } + Some(()) + } + + /// The ` ` prefix that `1`, `2` and `u` records share. + fn prefix(&mut self) -> Option<(u8, u8, Option)> { + self.next()?; + let &[x, y] = self.next()? else { + return None; + }; + Some((x, y, parse_submodule(self.next()?))) + } + + fn tail(&self) -> &'a [u8] { + self.rest + } +} + +/// `N...` when the entry is not a submodule, otherwise `S`. +fn parse_submodule(field: &[u8]) -> Option { + let &[b'S', c, m, u] = field else { + return None; + }; + Some(SubmoduleState { + commit_changed: c == b'C', + modified_content: m == b'M', + has_untracked: u == b'U', + }) +} + +/// `R100` / `C75`. The letter repeats what `XY` already said, so only the +/// number is kept. +fn parse_score(field: &[u8]) -> Option { + let digits = std::str::from_utf8(field.get(1..)?).ok()?; + digits.parse::().ok().map(|n| n.min(100) as u8) +} + +/// `+2 -1`, from `# branch.ab`. +/// +/// `git status --no-ahead-behind` prints `+? -?` rather than dropping the line +/// (measured on git 2.50), so an unparsable pair has to mean *unknown*. Reading +/// it as zero would render as "in sync", which is the one wrong answer worse +/// than no answer at all. +fn parse_ab(value: &str) -> Option<(u32, u32)> { + let (ahead, behind) = value.split_once(' ')?; + Some(( + ahead.strip_prefix('+')?.parse().ok()?, + behind.strip_prefix('-')?.parse().ok()?, + )) +} + +fn head_state(oid: Option, head_name: Option) -> HeadState { + let branch = head_name.unwrap_or_default(); + match oid { + Some(oid) if oid == "(initial)" => HeadState::Unborn { branch }, + Some(oid) if branch == "(detached)" => HeadState::Detached { oid }, + Some(oid) => HeadState::Branch { name: branch, oid }, + None => HeadState::Unborn { branch }, + } +} + +/// The whole working tree state for the repository containing `cwd`, or `None` +/// if there is no repository there. +/// +/// Three round trips in the common case — `rev-parse`, `status`, `read_dir` — +/// and each one is an RPC on a remote workspace, which is why none of them is +/// split into the several calls that would read more naturally. +pub fn probe_status(host: &dyn Host, cwd: &Path) -> Option { + let paths = super::git( + host, + cwd, + &[ + "rev-parse", + "--path-format=absolute", + "--show-toplevel", + "--git-dir", + "--git-common-dir", + ], + )?; + let mut lines = paths.lines().map(|l| l.trim_end_matches(['\n', '\r'])); + let root = PathBuf::from(lines.next()?); + let git_dir = lines.next(); + let home = super::repo_home(&root, git_dir, lines.next()); + let git_dir = PathBuf::from(git_dir?); + + let out = host.git(cwd, STATUS_ARGS).ok()?; + if !out.success() { + return None; + } + let mut parsed = parse_porcelain_v2(&out.stdout); + if parsed.ahead_behind.is_none() { + if let Some(upstream) = parsed.upstream.clone() { + parsed.ahead_behind = rev_list_ahead_behind(host, cwd, &upstream); + } + } + + let listing = host.read_dir(&git_dir, None).unwrap_or_default(); + let operation = detect_operation(host, &git_dir, &listing); + let prefilled_message = + operation.and_then(|_| read_prefilled_message(host, &git_dir, &listing)); + Some(parsed.into_status(root, home, operation, prefilled_message)) +} + +/// Ask for ahead/behind again when the header could not say. +/// +/// Only reached when `# branch.ab` is missing or unreadable, which measurement +/// says is rare: on git 2.50, `status.aheadBehind=false` does *not* suppress the +/// line for porcelain v2 (that config is documented as applying to non-porcelain +/// formats only), and `--no-ahead-behind` prints `+? -?` rather than dropping +/// it. This is a fallback for old git and for the configurations we did not +/// measure — not dead code, but not the normal path either. Do not promote it to +/// unconditional: on a remote workspace every call here is another RPC on a +/// refresh that already made three. +fn rev_list_ahead_behind(host: &dyn Host, cwd: &Path, upstream: &str) -> Option<(u32, u32)> { + let range = format!("{upstream}...HEAD"); + let out = super::git(host, cwd, &["rev-list", "--left-right", "--count", &range])?; + // Left of the `...` is the upstream, so the left count is what we are + // behind by — the opposite order from `# branch.ab`. + let (behind, ahead) = out.trim().split_once('\t')?; + Some((ahead.trim().parse().ok()?, behind.trim().parse().ok()?)) +} + +/// Which sequencer operation, if any, is parked in this repository. +/// +/// The order mirrors git's own in `wt_status_print_state`, and it matters more +/// than any individual test does: a conflicted `am` leaves `rebase-apply` +/// behind, a rebase stopped on a pick leaves `MERGE_MSG` behind, and a merge +/// leaves `AUTO_MERGE` behind. Only the precedence tells them apart. +fn detect_operation(host: &dyn Host, git_dir: &Path, listing: &[Entry]) -> Option { + let has = |name: &str| listing.iter().any(|e| e.name == name); + if has("MERGE_HEAD") { + return Some(RepoOperation::Merge); + } + if has("rebase-apply") { + // `applying` is the only thing separating `git am` from a rebase on the + // apply backend, and it sits one level down. The extra round trip is + // paid only while a rebase is actually parked. + return Some( + match dir_has(host, &git_dir.join("rebase-apply"), "applying") { + true => RepoOperation::Am, + false => RepoOperation::Rebase, + }, + ); + } + if has("rebase-merge") { + // Modern git writes `interactive` for *every* rebase on the merge + // backend, not only `-i` — measured on 2.50, where plain `git rebase` + // also makes `git status` say "interactive rebase in progress". Reading + // the same marker git reads keeps the two labels from disagreeing. + return Some( + match dir_has(host, &git_dir.join("rebase-merge"), "interactive") { + true => RepoOperation::RebaseInteractive, + false => RepoOperation::Rebase, + }, + ); + } + if has("CHERRY_PICK_HEAD") { + return Some(RepoOperation::CherryPick); + } + if has("REVERT_HEAD") { + return Some(RepoOperation::Revert); + } + if has("BISECT_LOG") { + return Some(RepoOperation::Bisect); + } + None +} + +fn dir_has(host: &dyn Host, dir: &Path, name: &str) -> bool { + host.read_dir(dir, None) + .is_ok_and(|listing| listing.iter().any(|e| e.name == name)) +} + +/// The message git already wrote for the operation in progress. +/// +/// Only read when something *is* in progress: outside a merge these files are +/// leftovers from the last commit, and pre-filling the box with a stale message +/// is how you accidentally commit it again. +fn read_prefilled_message(host: &dyn Host, git_dir: &Path, listing: &[Entry]) -> Option { + // `SQUASH_MSG` first: when both exist it is the more specific one. + for name in ["SQUASH_MSG", "MERGE_MSG"] { + if !listing.iter().any(|e| e.name == name) { + continue; + } + let Ok(bytes) = host.read_file(&git_dir.join(name), MAX_PREFILLED_MESSAGE) else { + continue; + }; + let text = String::from_utf8_lossy(&bytes).trim_end().to_string(); + if !text.is_empty() { + return Some(text); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + /// One NUL-terminated record. Samples are written this way because a string + /// literal with embedded NULs is unreadable, and because the separator is + /// exactly what half of these tests are about. + fn rec(parts: &[&str]) -> Vec { + let mut out = parts.concat().into_bytes(); + out.push(0); + out + } + + fn rec_bytes(prefix: &str, path: &[u8]) -> Vec { + let mut out = prefix.as_bytes().to_vec(); + out.extend_from_slice(path); + out.push(0); + out + } + + /// The five `# branch.*` lines with a real sha, so record tests do not have + /// to restate the header every time. + fn head_records() -> Vec { + [ + rec(&["# branch.oid 8d1b4a0e3c2f5b6a7d8e9f0a1b2c3d4e5f607182"]), + rec(&["# branch.head main"]), + ] + .concat() + } + + const SHA: &str = "8d1b4a0e3c2f5b6a7d8e9f0a1b2c3d4e5f607182"; + + /// A `1` record's fixed fields; only `XY` and the path ever vary here. + fn ordinary(xy: &str, path: &str) -> Vec { + rec(&[ + "1 ", + xy, + " N... 100644 100644 100644 ", + SHA, + " ", + SHA, + " ", + path, + ]) + } + + fn by_path<'a>(parsed: &'a ParsedStatus, path: &str) -> &'a StatusEntry { + parsed + .entries + .iter() + .find(|e| e.path.text == path) + .unwrap_or_else(|| panic!("no entry for {path}: {:?}", parsed.entries)) + } + + fn status_of(stdout: &[u8]) -> WorkingTreeStatus { + parse_porcelain_v2(stdout).into_status( + PathBuf::from("/repo"), + PathBuf::from("/repo"), + None, + None, + ) + } + + #[test] + fn a_full_branch_header_lands_in_every_field() { + let parsed = parse_porcelain_v2( + &[ + rec(&["# branch.oid ", SHA]), + rec(&["# branch.head feature/scm"]), + rec(&["# branch.upstream origin/feature/scm"]), + rec(&["# branch.ab +12 -3"]), + rec(&["# stash 4"]), + ] + .concat(), + ); + + assert_eq!( + parsed.head, + HeadState::Branch { + name: "feature/scm".into(), + oid: SHA.into(), + } + ); + assert_eq!(parsed.upstream.as_deref(), Some("origin/feature/scm")); + assert_eq!(parsed.ahead_behind, Some((12, 3))); + assert_eq!(parsed.stash_count, 4); + assert!(parsed.entries.is_empty()); + assert!(!parsed.truncated); + } + + #[test] + fn an_unborn_head_reports_its_branch_and_no_commits() { + let parsed = parse_porcelain_v2( + &[ + rec(&["# branch.oid (initial)"]), + rec(&["# branch.head main"]), + rec(&["? first.txt"]), + ] + .concat(), + ); + + assert_eq!( + parsed.head, + HeadState::Unborn { + branch: "main".into() + } + ); + assert!(!parsed.head.has_commits(), "reset HEAD would fatal here"); + assert_eq!(parsed.head.label(), "main"); + assert_eq!(parsed.entries.len(), 1, "the untracked file still parses"); + } + + #[test] + fn a_detached_head_keeps_the_sha_and_shows_it_short() { + let parsed = parse_porcelain_v2( + &[ + rec(&["# branch.oid ", SHA]), + rec(&["# branch.head (detached)"]), + ] + .concat(), + ); + + assert_eq!(parsed.head, HeadState::Detached { oid: SHA.into() }); + assert!(parsed.head.has_commits()); + assert_eq!(parsed.head.label(), "8d1b4a0"); + } + + #[test] + fn an_unknown_ahead_behind_pair_is_unknown_and_not_in_sync() { + // What `git status --no-ahead-behind` actually prints — measured on + // git 2.50, which does *not* drop the line. + let parsed = parse_porcelain_v2( + &[ + rec(&["# branch.upstream origin/main"]), + rec(&["# branch.ab +? -?"]), + ] + .concat(), + ); + assert_eq!(parsed.ahead_behind, None); + } + + #[test] + fn the_xy_pair_splits_staged_from_unstaged() { + let parsed = parse_porcelain_v2( + &[ + head_records(), + ordinary(".M", "unstaged.rs"), + ordinary("M.", "staged.rs"), + ordinary("MM", "both.rs"), + ordinary("A.", "added.rs"), + ordinary(".D", "gone-from-disk.rs"), + ordinary("D.", "staged-delete.rs"), + ] + .concat(), + ); + let staged: Vec<&str> = parsed + .entries + .iter() + .filter(|e| e.is_staged()) + .map(|e| e.path.as_str()) + .collect(); + let unstaged: Vec<&str> = parsed + .entries + .iter() + .filter(|e| e.is_unstaged()) + .map(|e| e.path.as_str()) + .collect(); + + assert_eq!( + staged, + ["staged.rs", "both.rs", "added.rs", "staged-delete.rs"] + ); + assert_eq!( + unstaged, + ["unstaged.rs", "both.rs", "gone-from-disk.rs"], + "`both.rs` belongs to both groups at once" + ); + + assert_eq!(by_path(&parsed, "both.rs").index, ChangeCode::Modified); + assert_eq!(by_path(&parsed, "both.rs").worktree, ChangeCode::Modified); + assert_eq!(by_path(&parsed, "added.rs").worktree, ChangeCode::None); + assert_eq!( + by_path(&parsed, "gone-from-disk.rs").worktree, + ChangeCode::Deleted + ); + assert_eq!( + by_path(&parsed, "gone-from-disk.rs").deco(), + DecoStatus::Deleted + ); + assert!(parsed.entries.iter().all(|e| e.submodule.is_none())); + } + + #[test] + fn a_rename_eats_its_old_path_and_nothing_else() { + // The regression this whole parser exists to avoid: `2` spends two NUL + // records, so a naive one-record-per-entry loop reads the row *behind* + // the rename as the old path and loses it. + let parsed = parse_porcelain_v2( + &[ + head_records(), + rec(&[ + "2 R. N... 100644 100644 100644 ", + SHA, + " ", + SHA, + " R100 src/new.rs", + ]), + rec(&["src/old.rs"]), + ordinary(".M", "after-the-rename.rs"), + rec(&["? untracked-behind-it.txt"]), + ] + .concat(), + ); + + assert_eq!( + parsed + .entries + .iter() + .map(|e| e.path.as_str()) + .collect::>(), + [ + "src/new.rs", + "after-the-rename.rs", + "untracked-behind-it.txt" + ], + "the record after the rename is a record, not the old path" + ); + + let renamed = by_path(&parsed, "src/new.rs"); + assert_eq!( + renamed.orig_path.as_ref().map(|p| p.as_str()), + Some("src/old.rs") + ); + assert_eq!(renamed.index, ChangeCode::Renamed); + assert_eq!(renamed.rename_score, Some(100)); + assert_eq!(renamed.deco(), DecoStatus::Renamed); + assert!(by_path(&parsed, "after-the-rename.rs").orig_path.is_none()); + } + + #[test] + fn a_copy_record_carries_its_similarity_score() { + let parsed = parse_porcelain_v2(&rec(&[ + "2 C. N... 100644 100644 100644 ", + SHA, + " ", + SHA, + " C75 copy.rs", + ])); + // Nothing followed it, so the old path is lost — but the file is not. + assert_eq!(parsed.entries.len(), 1); + assert_eq!(parsed.entries[0].rename_score, Some(75)); + assert_eq!(parsed.entries[0].index, ChangeCode::Copied); + } + + #[test] + fn unmerged_records_become_conflicts_and_stay_out_of_both_groups() { + let unmerged = |xy: &str, path: &str| { + rec(&[ + "u ", + xy, + " N... 100644 100644 100644 100644 ", + SHA, + " ", + SHA, + " ", + SHA, + " ", + path, + ]) + }; + let parsed = parse_porcelain_v2( + &[ + head_records(), + unmerged("UU", "both-modified.rs"), + unmerged("AA", "both-added.rs"), + unmerged("DU", "deleted-by-us.rs"), + ] + .concat(), + ); + + assert_eq!( + by_path(&parsed, "both-modified.rs").conflict, + Some(ConflictKind::BothModified) + ); + assert_eq!( + by_path(&parsed, "both-added.rs").conflict, + Some(ConflictKind::BothAdded) + ); + assert_eq!( + by_path(&parsed, "deleted-by-us.rs").conflict, + Some(ConflictKind::DeletedByUs) + ); + assert!( + !by_path(&parsed, "deleted-by-us.rs") + .conflict + .unwrap() + .ours_exists() + ); + + for entry in &parsed.entries { + assert_eq!(entry.kind, EntryKind::Unmerged); + assert!(!entry.is_staged(), "{} leaked into Staged", entry.path.text); + assert!( + !entry.is_unstaged(), + "{} leaked into Changes", + entry.path.text + ); + assert_eq!(entry.deco(), DecoStatus::Conflict); + } + } + + #[test] + fn untracked_and_ignored_records_parse_without_fields() { + let parsed = parse_porcelain_v2( + &[ + head_records(), + rec(&["? build/out.o"]), + // We never pass `--ignored`, but the parser must not choke the + // day someone does. + rec(&["! target/debug/tty7"]), + ] + .concat(), + ); + + let untracked = by_path(&parsed, "build/out.o"); + assert_eq!(untracked.kind, EntryKind::Untracked); + assert!(untracked.is_untracked()); + assert!(!untracked.is_staged() && !untracked.is_unstaged()); + assert_eq!(untracked.deco(), DecoStatus::Untracked); + + assert_eq!( + by_path(&parsed, "target/debug/tty7").kind, + EntryKind::Ignored + ); + } + + #[test] + fn a_submodule_reports_its_three_sub_states() { + let parsed = parse_porcelain_v2(&rec(&[ + "1 .M S.MU 160000 160000 160000 ", + SHA, + " ", + SHA, + " vendor/lib", + ])); + + assert_eq!( + parsed.entries[0].submodule, + Some(SubmoduleState { + commit_changed: false, + modified_content: true, + has_untracked: true, + }) + ); + } + + #[test] + fn paths_with_spaces_quotes_and_newlines_survive_intact() { + // Every one of these is C-quoted without `-z`, which is the entire + // reason the parser works on bytes instead of lines. + let parsed = parse_porcelain_v2( + &[ + head_records(), + ordinary(".M", "d i r/sp ace.rs"), + ordinary("M.", "quote\"file.rs"), + rec(&["? new\nline.txt"]), + ordinary(".M", "back\\slash.rs"), + ] + .concat(), + ); + + assert_eq!( + by_path(&parsed, "d i r/sp ace.rs").path.file_name(), + "sp ace.rs" + ); + assert_eq!( + by_path(&parsed, "quote\"file.rs").index, + ChangeCode::Modified + ); + assert_eq!(by_path(&parsed, "new\nline.txt").kind, EntryKind::Untracked); + assert_eq!(by_path(&parsed, "back\\slash.rs").path.parent(), ""); + assert_eq!(parsed.entries.len(), 4); + } + + #[test] + fn a_non_utf8_path_is_shown_but_never_acted_on() { + // `caf\xe9.rs` — latin-1, which a Linux filesystem is perfectly happy + // to hold and which cannot be carried by the control protocol. + let parsed = parse_porcelain_v2(&rec_bytes( + &format!("1 .M N... 100644 100644 100644 {SHA} {SHA} "), + b"caf\xe9.rs", + )); + + let entry = &parsed.entries[0]; + assert!(entry.path.lossy); + assert!(entry.path.text.starts_with("caf")); + assert_eq!( + entry.path.pathspec(), + None, + "the UI has to grey out staging this row" + ); + } + + #[test] + fn past_the_entry_cap_the_count_is_still_the_real_one() { + let mut stdout = head_records(); + let overflow = 25; + for i in 0..MAX_STATUS_ENTRIES + overflow { + stdout.extend(ordinary(".M", &format!("f{i}.rs"))); + } + let parsed = parse_porcelain_v2(&stdout); + + assert_eq!(parsed.entries.len(), MAX_STATUS_ENTRIES); + assert_eq!(parsed.total_entries, MAX_STATUS_ENTRIES + overflow); + assert!(parsed.truncated); + assert_eq!( + parsed.entries[0].path.as_str(), + "f0.rs", + "the kept entries are the first ones, not a random slice" + ); + } + + #[test] + fn the_index_decorates_every_ancestor_up_to_the_root() { + let status = status_of( + &[ + head_records(), + ordinary(".M", "crates/core/src/git/status.rs"), + rec(&["? docs/notes.md"]), + ] + .concat(), + ); + let index = StatusIndex::build(&status); + + assert_eq!(index.root, PathBuf::from("/repo")); + assert_eq!( + index.file("crates/core/src/git/status.rs"), + Some(DecoStatus::Modified) + ); + for dir in [ + "crates", + "crates/core", + "crates/core/src", + "crates/core/src/git", + ] { + assert_eq!( + index.dir(dir), + Some(DirRollup { + changed: true, + conflict: false + }), + "{dir} lost its rollup" + ); + } + assert_eq!(index.file("docs/notes.md"), Some(DecoStatus::Untracked)); + assert_eq!(index.dir("nowhere"), None); + assert!(!index.is_empty()); + } + + #[test] + fn one_conflict_colours_the_whole_path_to_the_root() { + let status = status_of( + &[ + head_records(), + ordinary(".M", "a/b/quiet.rs"), + rec(&[ + "u UU N... 100644 100644 100644 100644 ", + SHA, + " ", + SHA, + " ", + SHA, + " a/b/c/clash.rs", + ]), + ] + .concat(), + ); + let index = StatusIndex::build(&status); + + for dir in ["a", "a/b", "a/b/c"] { + assert!(index.dir(dir).unwrap().conflict, "{dir} should be red"); + } + assert_eq!(index.file("a/b/quiet.rs"), Some(DecoStatus::Modified)); + } + + #[test] + fn a_renames_old_directory_is_rolled_up_too() { + let status = status_of( + &[ + head_records(), + rec(&[ + "2 R. N... 100644 100644 100644 ", + SHA, + " ", + SHA, + " R090 new/home.rs", + ]), + rec(&["old/home.rs"]), + ] + .concat(), + ); + let index = StatusIndex::build(&status); + + assert!(index.dir("new").unwrap().changed); + assert!( + index.dir("old").unwrap().changed, + "the directory it left lost a file" + ); + } + + #[test] + fn past_the_decoration_cap_only_directories_stay_lit() { + let mut stdout = head_records(); + for i in 0..MAX_DECORATED_FILES + 1 { + stdout.extend(ordinary(".M", &format!("deep/dir/f{i}.rs"))); + } + let index = StatusIndex::build(&status_of(&stdout)); + + assert!(index.files_dropped); + assert_eq!(index.file("deep/dir/f0.rs"), None); + assert!( + index.dir("deep").unwrap().changed, + "folders still say where" + ); + assert!(index.dir("deep/dir").unwrap().changed); + } + + // ----- against a real repository ------------------------------------- + + struct Scratch(PathBuf); + + impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + fn scratch(name: &str) -> Option { + let dir = std::env::temp_dir().join(format!("tty7-scm-status-{name}")); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).ok()?; + Some(Scratch(dir)) + } + + /// Runs git with the identity and signing settings pinned, so the test does + /// not depend on whatever is in the developer's `~/.gitconfig`. + fn run(host: &dyn Host, cwd: &Path, args: &[&str]) -> bool { + let mut full = vec![ + "-c", + "user.name=tty7 test", + "-c", + "user.email=test@tty7.invalid", + "-c", + "commit.gpgsign=false", + ]; + full.extend_from_slice(args); + host.git(cwd, &full).map(|o| o.success()).unwrap_or(false) + } + + #[test] + fn a_real_repository_reports_all_four_shapes_at_once() { + let host = crate::host::local::LocalHost::new(); + let Some(scratch) = scratch("real-repo") else { + return; + }; + let repo = &scratch.0; + + if !run(&*host, repo, &["init", "--quiet"]) { + return; // no git on this machine + } + // Not `init -b`: that is git 2.28+, and the branch name is asserted on. + assert!(run( + &*host, + repo, + &["symbolic-ref", "HEAD", "refs/heads/main"] + )); + std::fs::write(repo.join("kept.txt"), "one\n").unwrap(); + std::fs::write(repo.join("moved.txt"), "a\nb\nc\nd\ne\nf\ng\nh\n").unwrap(); + assert!(run(&*host, repo, &["add", "-A"])); + assert!(run(&*host, repo, &["commit", "--quiet", "-m", "base"])); + + std::fs::write(repo.join("staged.txt"), "new\n").unwrap(); + assert!(run(&*host, repo, &["add", "staged.txt"])); + std::fs::write(repo.join("kept.txt"), "one\ntwo\n").unwrap(); + assert!(run(&*host, repo, &["mv", "moved.txt", "renamed.txt"])); + std::fs::write(repo.join("untracked.txt"), "loose\n").unwrap(); + + let status = probe_status(&*host, repo).expect("a repository was just created here"); + + match &status.head { + HeadState::Branch { name, oid } => { + assert_eq!(name, "main"); + assert_eq!(oid.len(), 40, "the header carries the full sha: {oid}"); + } + other => panic!("expected a branch, got {other:?}"), + } + assert_eq!(status.upstream, None); + assert_eq!(status.ahead_behind, None, "no upstream, no number"); + assert_eq!(status.operation, None); + assert_eq!(status.prefilled_message, None); + assert_eq!(status.stash_count, 0); + assert!(!status.truncated); + assert!(!status.is_clean()); + assert_eq!(status.root, std::fs::canonicalize(repo).unwrap()); + assert_eq!(status.home, status.root); + + fn names(mut v: Vec<&str>) -> Vec<&str> { + v.sort_unstable(); + v + } + assert_eq!( + names(status.staged().map(|e| e.path.as_str()).collect()), + ["renamed.txt", "staged.txt"] + ); + assert_eq!( + names(status.unstaged().map(|e| e.path.as_str()).collect()), + ["kept.txt"] + ); + assert_eq!( + names(status.untracked().map(|e| e.path.as_str()).collect()), + ["untracked.txt"] + ); + assert_eq!(status.conflicts().count(), 0); + + let renamed = status + .entries + .iter() + .find(|e| e.path.as_str() == "renamed.txt") + .unwrap(); + assert_eq!(renamed.index, ChangeCode::Renamed); + assert_eq!( + renamed.orig_path.as_ref().map(|p| p.as_str()), + Some("moved.txt") + ); + + let index = StatusIndex::build(&status); + assert_eq!(index.file("kept.txt"), Some(DecoStatus::Modified)); + assert_eq!(index.file("untracked.txt"), Some(DecoStatus::Untracked)); + } + + #[test] + fn a_merge_in_progress_is_named_and_pre_fills_its_message() { + let host = crate::host::local::LocalHost::new(); + let Some(scratch) = scratch("merge-op") else { + return; + }; + let repo = &scratch.0; + + if !run(&*host, repo, &["init", "--quiet"]) { + return; + } + assert!(run( + &*host, + repo, + &["symbolic-ref", "HEAD", "refs/heads/main"] + )); + std::fs::write(repo.join("c.txt"), "base\n").unwrap(); + assert!(run(&*host, repo, &["add", "-A"])); + assert!(run(&*host, repo, &["commit", "--quiet", "-m", "base"])); + assert!(run(&*host, repo, &["checkout", "--quiet", "-b", "other"])); + std::fs::write(repo.join("c.txt"), "theirs\n").unwrap(); + assert!(run(&*host, repo, &["commit", "--quiet", "-am", "theirs"])); + assert!(run(&*host, repo, &["checkout", "--quiet", "main"])); + std::fs::write(repo.join("c.txt"), "ours\n").unwrap(); + assert!(run(&*host, repo, &["commit", "--quiet", "-am", "ours"])); + // Expected to fail — that is the point. + run(&*host, repo, &["merge", "other"]); + + let status = probe_status(&*host, repo).expect("still a repository mid-merge"); + assert_eq!(status.operation, Some(RepoOperation::Merge)); + assert!( + status + .prefilled_message + .as_deref() + .is_some_and(|m| m.contains("other")), + "MERGE_MSG should seed the commit box: {:?}", + status.prefilled_message + ); + assert_eq!(status.conflicts().count(), 1); + assert_eq!( + status.conflicts().next().unwrap().conflict, + Some(ConflictKind::BothModified) + ); + } + + #[test] + fn an_upstream_gives_ahead_and_behind_without_a_second_command() { + let host = crate::host::local::LocalHost::new(); + let Some(scratch) = scratch("upstream") else { + return; + }; + let remote = scratch.0.join("remote.git"); + let repo = scratch.0.join("clone"); + std::fs::create_dir_all(&repo).unwrap(); + + if !run( + &*host, + &scratch.0, + &["init", "--quiet", "--bare", "remote.git"], + ) { + return; + } + assert!(run(&*host, &repo, &["init", "--quiet"])); + assert!(run( + &*host, + &repo, + &["symbolic-ref", "HEAD", "refs/heads/main"] + )); + std::fs::write(repo.join("f.txt"), "one\n").unwrap(); + assert!(run(&*host, &repo, &["add", "-A"])); + assert!(run(&*host, &repo, &["commit", "--quiet", "-m", "one"])); + assert!(run( + &*host, + &repo, + &["remote", "add", "origin", &remote.to_string_lossy()] + )); + assert!(run( + &*host, + &repo, + &["push", "--quiet", "-u", "origin", "main"] + )); + std::fs::write(repo.join("f.txt"), "two\n").unwrap(); + assert!(run(&*host, &repo, &["commit", "--quiet", "-am", "two"])); + + let status = probe_status(&*host, &repo).expect("a repository with a remote"); + assert_eq!(status.upstream.as_deref(), Some("origin/main")); + // Straight from `# branch.ab`; the `rev-list` fallback never runs here. + assert_eq!( + status.ahead_behind, + Some((1, 0)), + "ahead first, behind second" + ); + assert!(status.is_clean()); + } + + #[test] + fn outside_a_repository_there_is_no_status() { + let host = crate::host::local::LocalHost::new(); + let Some(scratch) = scratch("not-a-repo") else { + return; + }; + assert_eq!(probe_status(&*host, &scratch.0), None); + assert_eq!(probe_status(&*host, Path::new("/no/such/tty7/path")), None); + } +}