From c3233bb39bcbb9819bfcc2cf65de2a11423239b2 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:13:08 +0800 Subject: [PATCH] refactor(git): split core::git into a module tree and move the diff model into it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The source control work needs four kinds of git knowledge — how to run a process, what the working tree looks like, what a patch looks like, and what the history looks like — and they were about to pile into one 386-line file. core/git.rs becomes core/git/{mod,status,diff,log,ops}.rs. Every existing import path is unchanged: mod.rs still holds the process layer and re-exports nothing it did not already export. src/terminal/git_diff.rs moves wholesale into core::git::diff, leaving a pub-use shim so diff_overlay.rs and right_panel.rs compile untouched. It never had a gpui dependency, and the headless server should parse a patch with the same code the GUI does. Also lands the shared pieces the panel is built on, all inert for now: - RecordSplitter, LineSplitter's sibling for the two git formats that are not newline delimited (porcelain v2 -z, and log with an ASCII record separator). It hands out &[u8] because a path in a -z status need not be UTF-8. - git_output_with_env, so network operations can be told they have no terminal to prompt at without the read paths inheriting that. - The status/log/ops type contracts: the XY pair, unmerged stages, HeadState, RepoPath (which refuses to produce a pathspec it cannot represent), the row-local graph edge model, and GitOp with its destructive() policy datum. No behaviour changes. 867 core and 1036 app tests still pass. --- crates/tty7-core/Cargo.toml | 6 + crates/tty7-core/src/core/git/diff.rs | 732 +++++++++++++++++ crates/tty7-core/src/core/git/log.rs | 173 ++++ .../tty7-core/src/core/{git.rs => git/mod.rs} | 105 +++ crates/tty7-core/src/core/git/ops.rs | 221 ++++++ crates/tty7-core/src/core/git/status.rs | 427 ++++++++++ src/terminal/git_diff.rs | 738 +----------------- src/terminal/git_status.rs | 2 +- 8 files changed, 1671 insertions(+), 733 deletions(-) create mode 100644 crates/tty7-core/src/core/git/diff.rs create mode 100644 crates/tty7-core/src/core/git/log.rs rename crates/tty7-core/src/core/{git.rs => git/mod.rs} (75%) create mode 100644 crates/tty7-core/src/core/git/ops.rs create mode 100644 crates/tty7-core/src/core/git/status.rs diff --git a/crates/tty7-core/Cargo.toml b/crates/tty7-core/Cargo.toml index 50520464..d58d6dc7 100644 --- a/crates/tty7-core/Cargo.toml +++ b/crates/tty7-core/Cargo.toml @@ -48,6 +48,12 @@ sha2 = "0.11" # implementation. ignore = "0.4" +# Lane assignment for the commit graph (`core::git::log`) keeps a couple of +# parents and a handful of edges per row; a SmallVec keeps those off the heap +# for the shapes that make up almost all of a real history. Already in the tree +# via the GUI, so this pins no new code. +smallvec.workspace = true + # Cross-platform PTY for the daemon: a Unix pty on Unix, ConPTY on Windows, # behind one blocking `Read`/`Write`/`resize` API. This is what lets # `daemon::pane` share a single code path across platforms instead of diff --git a/crates/tty7-core/src/core/git/diff.rs b/crates/tty7-core/src/core/git/diff.rs new file mode 100644 index 00000000..5bf692a3 --- /dev/null +++ b/crates/tty7-core/src/core/git/diff.rs @@ -0,0 +1,732 @@ +use std::path::{Path, PathBuf}; + +use crate::core::git; +use crate::host::Host; + +pub const MAX_LINES_PER_FILE: usize = 2000; + +pub const MAX_TOTAL_LINES: usize = 20_000; + +pub const MAX_FILES_WITH_HUNKS: usize = 500; + +pub const AUTO_COLLAPSE_LINES: u32 = 400; + +pub const AUTO_COLLAPSE_TOTAL_LINES: usize = 8_000; + +pub const AUTO_COLLAPSE_TOTAL_FILES: usize = 100; + +pub const MAX_RENDERED_FILES: usize = 300; + +pub const MAX_UNTRACKED: usize = 500; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Truncation { + PerFile, + Budget, +} + +#[derive(Clone, PartialEq, Eq, Debug, Default)] +pub struct DiffSnapshot { + pub root: PathBuf, + pub branch: String, + pub files: Vec, + pub untracked: Vec, + pub untracked_total: usize, + pub read_failed: bool, +} + +impl DiffSnapshot { + pub fn totals(&self) -> (u32, u32) { + self.files + .iter() + .fold((0, 0), |(a, r), f| (a + f.added, r + f.removed)) + } + + pub fn untracked_count(&self) -> usize { + self.untracked_total.max(self.untracked.len()) + } + + pub fn stats(&self) -> DiffStats { + let mut added = 0u32; + let mut removed = 0u32; + let mut retained_lines = 0usize; + let mut budget_exhausted = false; + let mut per_file_truncated = false; + for file in &self.files { + added += file.added; + removed += file.removed; + retained_lines += file.hunks.iter().map(|h| h.lines.len()).sum::(); + match file.truncated { + Some(Truncation::Budget) => budget_exhausted = true, + Some(Truncation::PerFile) => per_file_truncated = true, + None => {} + } + } + let untracked_count = self.untracked_count(); + DiffStats { + totals: (added, removed), + retained_lines, + untracked_count, + oversized: self.files.len() > AUTO_COLLAPSE_TOTAL_FILES + || retained_lines > AUTO_COLLAPSE_TOTAL_LINES, + budget_exhausted, + per_file_truncated, + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub struct DiffStats { + pub totals: (u32, u32), + pub retained_lines: usize, + pub untracked_count: usize, + pub oversized: bool, + pub budget_exhausted: bool, + pub per_file_truncated: bool, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum FileStatus { + Added, + Modified, + Deleted, + Renamed, +} + +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct FileDiff { + pub path: String, + pub old_path: Option, + pub status: FileStatus, + pub added: u32, + pub removed: u32, + pub binary: bool, + pub truncated: Option, + pub hunks: Vec, +} + +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct Hunk { + pub header: String, + pub lines: Vec, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum LineKind { + Context, + Added, + Removed, +} + +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct DiffLine { + pub kind: LineKind, + pub old_no: Option, + pub new_no: Option, + pub text: String, +} + +pub fn probe(host: &dyn Host, cwd: &Path) -> Option { + let root = git::git(host, cwd, &["rev-parse", "--show-toplevel"])?; + let root = PathBuf::from(root.trim_end_matches(['\n', '\r'])); + let branch = git::branch_name(host, cwd)?; + let mut parser = DiffParser::default(); + let diffed = host.git_lines( + cwd, + &["diff", "--no-color", "--no-ext-diff", "-M", "HEAD"], + &mut |line| parser.push_line(line), + ); + let files = match diffed { + Ok(Some(0)) => parser.finish(), + _ => Vec::new(), + }; + let mut untracked: Vec = Vec::new(); + let mut untracked_total = 0usize; + let listed = host.git_lines( + cwd, + &["ls-files", "--others", "--exclude-standard", "--full-name"], + &mut |line| { + untracked_total += 1; + if untracked.len() < MAX_UNTRACKED { + untracked.push(line.to_string()); + } + }, + ); + if !matches!(listed, Ok(Some(0))) { + untracked.clear(); + untracked_total = 0; + } + Some(DiffSnapshot { + root, + branch, + files, + untracked, + untracked_total, + read_failed: !matches!(diffed, Ok(Some(0))) || !matches!(listed, Ok(Some(0))), + }) +} + +#[cfg(test)] +pub fn parse_unified(out: &str) -> Vec { + let mut parser = DiffParser::default(); + for line in out.lines() { + parser.push_line(line); + } + parser.finish() +} + +#[derive(Default)] +pub struct DiffParser { + files: Vec, + old_no: u32, + new_no: u32, + file_lines: usize, + total_lines: usize, + files_with_hunks: usize, + in_hunk: bool, +} + +impl DiffParser { + pub fn push_line(&mut self, line: &str) { + if let Some(rest) = line.strip_prefix("diff --git ") { + let (old_p, new_p) = parse_git_header_paths(rest); + self.files.push(FileDiff { + path: new_p.clone(), + old_path: (old_p != new_p).then_some(old_p), + status: FileStatus::Modified, + added: 0, + removed: 0, + binary: false, + truncated: None, + hunks: Vec::new(), + }); + self.file_lines = 0; + self.in_hunk = false; + return; + } + let Some(file) = self.files.last_mut() else { + return; + }; + if line.starts_with("new file mode") { + file.status = FileStatus::Added; + return; + } + if line.starts_with("deleted file mode") { + file.status = FileStatus::Deleted; + return; + } + if line.starts_with("rename from ") { + file.status = FileStatus::Renamed; + return; + } + if line.starts_with("Binary files ") || line.starts_with("GIT binary patch") { + file.binary = true; + return; + } + if !self.in_hunk + && (line.starts_with("--- ") || line.starts_with("+++ ") || !is_hunk_line(line)) + && !line.starts_with("@@") + { + return; + } + if line.starts_with("@@") { + self.in_hunk = true; + if file.truncated.is_some() { + return; + } + let first_hunk = file.hunks.is_empty(); + if (first_hunk && self.files_with_hunks >= MAX_FILES_WITH_HUNKS) + || self.total_lines >= MAX_TOTAL_LINES + { + file.truncated = Some(Truncation::Budget); + return; + } + if first_hunk { + self.files_with_hunks += 1; + } + let (o, n) = parse_hunk_starts(line).unwrap_or((0, 0)); + self.old_no = o; + self.new_no = n; + file.hunks.push(Hunk { + header: line.to_string(), + lines: Vec::new(), + }); + return; + } + if !self.in_hunk { + return; + } + let (kind, text) = match line.as_bytes().first() { + Some(b'+') => (LineKind::Added, &line[1..]), + Some(b'-') => (LineKind::Removed, &line[1..]), + Some(b' ') => (LineKind::Context, &line[1..]), + _ => return, + }; + match kind { + LineKind::Added => file.added += 1, + LineKind::Removed => file.removed += 1, + LineKind::Context => {} + } + if file.truncated.is_some() { + return; + } + self.file_lines += 1; + if self.file_lines > MAX_LINES_PER_FILE { + file.truncated = Some(Truncation::PerFile); + return; + } + if self.total_lines >= MAX_TOTAL_LINES { + file.truncated = Some(Truncation::Budget); + return; + } + let Some(hunk) = file.hunks.last_mut() else { + return; + }; + let (o, n) = match kind { + LineKind::Added => { + let n = self.new_no; + self.new_no += 1; + (None, Some(n)) + } + LineKind::Removed => { + let o = self.old_no; + self.old_no += 1; + (Some(o), None) + } + LineKind::Context => { + let (o, n) = (self.old_no, self.new_no); + self.old_no += 1; + self.new_no += 1; + (Some(o), Some(n)) + } + }; + hunk.lines.push(DiffLine { + kind, + old_no: o, + new_no: n, + text: text.to_string(), + }); + self.total_lines += 1; + } + + pub fn finish(self) -> Vec { + self.files + } +} + +fn is_hunk_line(line: &str) -> bool { + matches!(line.as_bytes().first(), Some(b'+' | b'-' | b' ' | b'\\')) || line.is_empty() +} + +fn parse_git_header_paths(rest: &str) -> (String, String) { + if rest.starts_with('"') { + let parts: Vec = parse_quoted_pair(rest); + if parts.len() == 2 { + return (strip_prefix_ab(&parts[0]), strip_prefix_ab(&parts[1])); + } + } + if let Some(idx) = rest.rfind(" b/") { + let old = &rest[..idx]; + let new = &rest[idx + 1..]; + return (strip_prefix_ab(old), strip_prefix_ab(new)); + } + (rest.to_string(), rest.to_string()) +} + +fn parse_quoted_pair(s: &str) -> Vec { + let mut parts = Vec::new(); + let mut cur = String::new(); + let mut in_quote = false; + let mut escaped = false; + for ch in s.chars() { + if escaped { + cur.push(ch); + escaped = false; + continue; + } + match ch { + '\\' if in_quote => escaped = true, + '"' => { + if in_quote { + parts.push(std::mem::take(&mut cur)); + } + in_quote = !in_quote; + } + _ if in_quote => cur.push(ch), + _ => {} + } + } + parts +} + +fn strip_prefix_ab(p: &str) -> String { + p.strip_prefix("a/") + .or_else(|| p.strip_prefix("b/")) + .unwrap_or(p) + .to_string() +} + +fn parse_hunk_starts(line: &str) -> Option<(u32, u32)> { + let rest = line.strip_prefix("@@ -")?; + let (old_part, rest) = rest.split_once(" +")?; + let (new_part, _) = rest.split_once(" @@")?; + let old = old_part.split(',').next()?.parse().ok()?; + let new = new_part.split(',').next()?.parse().ok()?; + Some((old, new)) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE: &str = "\ +diff --git a/src/main.rs b/src/main.rs +index 1111111..2222222 100644 +--- a/src/main.rs ++++ b/src/main.rs +@@ -10,4 +10,5 @@ fn main() { + let a = 1; +-let b = old(); ++let b = new(); ++let c = 3; + done(); +diff --git a/docs/new.md b/docs/new.md +new file mode 100644 +index 0000000..3333333 +--- /dev/null ++++ b/docs/new.md +@@ -0,0 +1,2 @@ ++hello ++world +diff --git a/gone.txt b/gone.txt +deleted file mode 100644 +index 4444444..0000000 +--- a/gone.txt ++++ /dev/null +@@ -1,1 +0,0 @@ +-bye +diff --git a/img.png b/img.png +index 5555555..6666666 100644 +Binary files a/img.png and b/img.png differ +"; + + #[test] + fn parses_the_four_file_shapes() { + let files = parse_unified(SAMPLE); + assert_eq!(files.len(), 4); + + let m = &files[0]; + assert_eq!(m.path, "src/main.rs"); + assert_eq!(m.status, FileStatus::Modified); + assert_eq!((m.added, m.removed), (2, 1)); + assert_eq!(m.hunks.len(), 1); + assert_eq!(m.hunks[0].header, "@@ -10,4 +10,5 @@ fn main() {"); + let lines = &m.hunks[0].lines; + assert_eq!(lines.len(), 5); + assert_eq!((lines[0].old_no, lines[0].new_no), (Some(10), Some(10))); + assert_eq!(lines[1].kind, LineKind::Removed); + assert_eq!(lines[1].old_no, Some(11)); + assert_eq!(lines[1].new_no, None); + assert_eq!(lines[2].kind, LineKind::Added); + assert_eq!(lines[2].new_no, Some(11)); + assert_eq!(lines[3].new_no, Some(12)); + assert_eq!(lines[3].text, "let c = 3;"); + assert_eq!((lines[4].old_no, lines[4].new_no), (Some(12), Some(13))); + + let a = &files[1]; + assert_eq!(a.status, FileStatus::Added); + assert_eq!((a.added, a.removed), (2, 0)); + + let d = &files[2]; + assert_eq!(d.status, FileStatus::Deleted); + assert_eq!((d.added, d.removed), (0, 1)); + + let b = &files[3]; + assert!(b.binary); + assert!(b.hunks.is_empty()); + } + + #[test] + fn parses_renames() { + let out = "\ +diff --git a/old/name.rs b/new/name.rs +similarity index 100% +rename from old/name.rs +rename to new/name.rs +"; + let files = parse_unified(out); + assert_eq!(files.len(), 1); + assert_eq!(files[0].status, FileStatus::Renamed); + assert_eq!(files[0].path, "new/name.rs"); + assert_eq!(files[0].old_path.as_deref(), Some("old/name.rs")); + assert_eq!((files[0].added, files[0].removed), (0, 0)); + } + + #[test] + fn parses_quoted_paths() { + let out = "diff --git \"a/has space.txt\" \"b/has space.txt\"\n"; + let files = parse_unified(out); + assert_eq!(files[0].path, "has space.txt"); + assert_eq!(files[0].old_path, None); + } + + #[test] + fn triple_dash_content_line_is_kept() { + let out = "\ +diff --git a/x.md b/x.md +index 1111111..2222222 100644 +--- a/x.md ++++ b/x.md +@@ -1,2 +1,1 @@ + keep +---- a heading rule +"; + let files = parse_unified(out); + let lines = &files[0].hunks[0].lines; + assert_eq!(lines.len(), 2); + assert_eq!(lines[1].kind, LineKind::Removed); + assert_eq!(lines[1].text, "--- a heading rule"); + } + + #[test] + fn caps_lines_per_file_but_keeps_counting() { + let mut out = String::from( + "diff --git a/big.txt b/big.txt\nindex 1..2 100644\n--- a/big.txt\n+++ b/big.txt\n@@ -0,0 +1,3000 @@\n", + ); + for i in 0..3000 { + out.push_str(&format!("+line {i}\n")); + } + let files = parse_unified(&out); + assert_eq!(files[0].truncated, Some(Truncation::PerFile)); + assert_eq!(files[0].added, 3000); + let kept: usize = files[0].hunks.iter().map(|h| h.lines.len()).sum(); + assert_eq!(kept, MAX_LINES_PER_FILE); + } + + #[test] + fn skips_no_newline_marker() { + let out = "\ +diff --git a/x b/x +index 1..2 100644 +--- a/x ++++ b/x +@@ -1,1 +1,1 @@ +-old +\\ No newline at end of file ++new +\\ No newline at end of file +"; + let files = parse_unified(out); + assert_eq!(files[0].hunks[0].lines.len(), 2); + assert_eq!((files[0].added, files[0].removed), (1, 1)); + } + + #[test] + fn snapshot_totals() { + let snap = DiffSnapshot { + files: parse_unified(SAMPLE), + ..Default::default() + }; + assert_eq!(snap.totals(), (4, 2)); + } + + fn many_files(files: usize, lines_each: usize) -> String { + let mut out = String::new(); + for f in 0..files { + out.push_str(&format!( + "diff --git a/f{f}.rs b/f{f}.rs\nindex 1..2 100644\n--- a/f{f}.rs\n+++ b/f{f}.rs\n@@ -0,0 +1,{lines_each} @@\n" + )); + for i in 0..lines_each { + out.push_str(&format!("+file {f} line {i}\n")); + } + } + out + } + + #[test] + fn repo_wide_budget_caps_retained_lines() { + let files = parse_unified(&many_files(300, 300)); + assert_eq!(files.len(), 300, "every file keeps its header row"); + let retained: usize = files + .iter() + .flat_map(|f| &f.hunks) + .map(|h| h.lines.len()) + .sum(); + assert!( + retained <= MAX_TOTAL_LINES, + "retained {retained} lines, budget is {MAX_TOTAL_LINES}" + ); + assert!( + files + .iter() + .any(|f| f.truncated == Some(Truncation::Budget)) + ); + } + + #[test] + fn repo_wide_budget_keeps_totals_exact() { + let snap = DiffSnapshot { + files: parse_unified(&many_files(300, 300)), + ..Default::default() + }; + assert_eq!(snap.totals(), (90_000, 0)); + assert!(snap.stats().budget_exhausted); + } + + #[test] + fn repo_wide_budget_caps_files_with_hunks() { + let files = parse_unified(&many_files(MAX_FILES_WITH_HUNKS + 50, 1)); + assert_eq!(files.len(), MAX_FILES_WITH_HUNKS + 50); + let with_hunks = files.iter().filter(|f| !f.hunks.is_empty()).count(); + assert_eq!(with_hunks, MAX_FILES_WITH_HUNKS); + assert_eq!( + files.iter().map(|f| f.added).sum::(), + (MAX_FILES_WITH_HUNKS + 50) as u32 + ); + assert_eq!(files.last().unwrap().truncated, Some(Truncation::Budget)); + } + + #[test] + fn small_diff_is_not_truncated() { + let snap = DiffSnapshot { + files: parse_unified(SAMPLE), + ..Default::default() + }; + assert!(snap.files.iter().all(|f| f.truncated.is_none())); + assert!(!snap.stats().oversized); + assert!(!snap.stats().budget_exhausted); + } + + #[test] + fn oversized_trips_on_files_or_lines() { + let by_files = DiffSnapshot { + files: parse_unified(&many_files(AUTO_COLLAPSE_TOTAL_FILES + 1, 1)), + ..Default::default() + }; + assert!(by_files.stats().oversized); + + let per_file = MAX_LINES_PER_FILE / 2; + let by_lines = DiffSnapshot { + files: parse_unified(&many_files( + AUTO_COLLAPSE_TOTAL_LINES / per_file + 1, + per_file, + )), + ..Default::default() + }; + assert!(by_lines.files.len() <= AUTO_COLLAPSE_TOTAL_FILES); + assert!(by_lines.stats().retained_lines > AUTO_COLLAPSE_TOTAL_LINES); + assert!(by_lines.stats().oversized); + } + + #[test] + fn truncated_file_counts_dash_prefixed_content() { + let mut out = many_files(MAX_FILES_WITH_HUNKS, 1); + out.push_str( + "diff --git a/late.md b/late.md\nindex 1..2 100644\n--- a/late.md\n+++ b/late.md\n@@ -1,2 +1,1 @@\n keep\n--- a heading rule\n", + ); + let files = parse_unified(&out); + let late = files.last().unwrap(); + assert_eq!(late.path, "late.md"); + assert_eq!(late.truncated, Some(Truncation::Budget)); + assert!(late.hunks.is_empty(), "no body kept past the file cap"); + assert_eq!((late.added, late.removed), (0, 1), "but the line counts"); + } + + #[test] + fn untracked_is_capped_but_counted() { + let mut untracked: Vec = Vec::new(); + let mut untracked_total = 0usize; + for i in 0..(MAX_UNTRACKED * 3) { + untracked_total += 1; + if untracked.len() < MAX_UNTRACKED { + untracked.push(format!("node_modules/p{i}/index.js")); + } + } + let snap = DiffSnapshot { + untracked, + untracked_total, + ..Default::default() + }; + assert_eq!(snap.untracked.len(), MAX_UNTRACKED, "retention is bounded"); + assert_eq!( + snap.untracked_count(), + MAX_UNTRACKED * 3, + "the count is not" + ); + } + + #[test] + #[ignore = "measurement, not an assertion"] + fn bench_stream_vs_buffer() { + use crate::core::git::{LineSplitter, git_output, git_stream}; + use std::time::Instant; + + let here = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let args = ["log", "-p", "-n", "400", "--no-color"]; + + let t = Instant::now(); + let Ok(out) = git_output(here, &args) else { + println!("no git here; skipping"); + return; + }; + let read = t.elapsed(); + let resident = out.stdout.len(); + let t = Instant::now(); + let buffered_files = parse_unified(&String::from_utf8_lossy(&out.stdout)); + let buffered_parse = t.elapsed(); + println!( + "buffered: {resident} bytes resident, read {read:?}, parse {buffered_parse:?}, \ + {} files", + buffered_files.len() + ); + drop(out); + + let t = Instant::now(); + let mut parser = DiffParser::default(); + let mut split = LineSplitter::default(); + let mut peak = 0usize; + git_stream(here, &args, |chunk| { + peak = peak.max(chunk.len()); + split.push(chunk, |line| parser.push_line(line)); + true + }) + .unwrap(); + split.finish(|line| parser.push_line(line)); + let streamed = t.elapsed(); + let streamed_files = parser.finish(); + println!( + "streamed: peak transient chunk {peak} bytes (vs {resident} resident), \ + read+parse {streamed:?}, {} files", + streamed_files.len() + ); + assert_eq!(buffered_files.len(), streamed_files.len()); + } + + #[test] + #[ignore = "measurement, not an assertion"] + fn bench_parse_budget() { + use std::time::Instant; + let out = many_files(300, 300); + println!("input: {} bytes, 300 files × 300 lines", out.len()); + let t = Instant::now(); + let files = parse_unified(&out); + let elapsed = t.elapsed(); + let retained: usize = files + .iter() + .flat_map(|f| &f.hunks) + .map(|h| h.lines.len()) + .sum(); + let bytes: usize = files + .iter() + .flat_map(|f| &f.hunks) + .flat_map(|h| &h.lines) + .map(|l| l.text.capacity() + std::mem::size_of::()) + .sum(); + println!( + "parse {elapsed:?} → {retained} retained lines, ~{} KiB of DiffLine text \ + (unbudgeted would be 90000 lines / ~{} KiB)", + bytes / 1024, + 90_000 * (24 + std::mem::size_of::()) / 1024, + ); + } +} diff --git a/crates/tty7-core/src/core/git/log.rs b/crates/tty7-core/src/core/git/log.rs new file mode 100644 index 00000000..e6c0d78a --- /dev/null +++ b/crates/tty7-core/src/core/git/log.rs @@ -0,0 +1,173 @@ +//! History: the commits themselves, the refs pointing at them, and the lane +//! layout the graph is drawn from. +//! +//! The lane assignment lives here, not in the renderer, for two reasons. It is +//! a pure function over `(sha, parents)` and therefore the part of the graph +//! most worth testing exhaustively; and gpui re-runs `render` on every notify, +//! so an O(commits × lanes) pass in a paint closure would be burned every +//! frame for a result that only changes when the history does. + +use smallvec::SmallVec; + +/// Full hex object id. Kept as `String` rather than `[u8; 20]` because sha256 +/// repositories exist and the extra allocation is noise next to the subject. +pub type Oid = String; + +/// Lane index as assigned by the layout pass — the *true* column, before the +/// renderer folds anything past its width cap into an overflow column. +pub type Lane = u16; + +/// Which palette entry a lane draws with. Equal to the lane it was created for +/// and never reassigned, which is what keeps a branch one colour for its whole +/// life: a branch holds the same lane from its tip until it is merged, because +/// the first parent inherits the lane in place and never migrates. +pub type ColorIdx = u16; + +pub const GRAPH_PAGE: usize = 200; +pub const MAX_GRAPH_COMMITS: usize = 5_000; +pub const MAX_LANES: Lane = 32; +pub const MAX_REFS: usize = 2_000; +pub const MAX_SUBJECT_BYTES: usize = 512; +pub const MAX_BODY_BYTES: usize = 8 * 1024; +pub const MAX_LOG_BYTES: usize = 16 * 1024 * 1024; + +/// Record separator for `log --pretty`. Deliberately not NUL: `git log -z` +/// already uses NUL between records, so a NUL field separator could only be +/// told apart by counting fields — and one NUL inside a commit message (git +/// objects allow it) would desynchronise the whole stream. RS and US cannot +/// occur in a sha, a refname, an ISO date or an address. +pub const REC_SEP: u8 = 0x1e; +pub const FIELD_SEP: u8 = 0x1f; + +/// A timestamp plus the author's own UTC offset, so times can be shown in the +/// zone they were written in. Parsed from `%aI` / `%cI`. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct OffsetTs { + pub unix: i64, + pub offset_minutes: i32, +} + +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct Signature { + pub name: String, + pub email: String, + pub at: OffsetTs, +} + +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)] +pub enum RefKind { + /// Sorts last so the highest-priority chip wins a `max()`. + Other, + RemoteBranch, + Tag, + LocalBranch, + Head, +} + +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct RefDeco { + pub kind: RefKind, + /// `refs/heads/feature/x` + pub full: String, + /// `feature/x` + pub short: String, + /// Carried the `HEAD -> ` prefix in `%D`. + pub is_head: bool, +} + +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct Commit { + pub oid: Oid, + pub parents: SmallVec<[Oid; 2]>, + pub author: Signature, + pub committer: Signature, + pub summary: String, + pub body: String, + pub refs: Vec, +} + +impl Commit { + pub fn short(&self) -> &str { + let n = self.oid.len().min(7); + &self.oid[..n] + } + + pub fn is_merge(&self) -> bool { + self.parents.len() > 1 + } +} + +/// One line inside a single row's band: from the row's top edge to its bottom. +/// +/// Row-local on purpose. A model that described whole polylines across rows +/// could not emit a line until its far end arrived, so a long-lived branch +/// would stay invisible until the page holding its parent loaded — the bug +/// Zed's own graph has. Here a row is final the moment it is produced, which +/// is also what makes paging free of visual reflow. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Edge { + /// Straight through the band without touching this row's node. + Pass { lane: Lane, color: ColorIdx }, + /// Comes down from `from` on the top edge and ends at this row's node. + In { from: Lane, color: ColorIdx }, + /// Leaves this row's node for `to` on the bottom edge. + Out { to: Lane, color: ColorIdx }, +} + +impl Edge { + pub fn color(self) -> ColorIdx { + match self { + Edge::Pass { color, .. } | Edge::In { color, .. } | Edge::Out { color, .. } => color, + } + } + + /// Paint order: pass-through lines first, so the node's own line lands on + /// top of anything crossing behind it. + pub fn paint_rank(&self) -> u8 { + match self { + Edge::Pass { .. } => 0, + Edge::In { .. } => 1, + Edge::Out { .. } => 2, + } + } +} + +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct GraphRow { + pub node: Lane, + pub color: ColorIdx, + /// 0 = root commit, 1 = ordinary, >1 = merge (>2 = octopus). + pub parents: u8, + pub edges: SmallVec<[Edge; 4]>, +} + +/// Which refs the log is walked from. +#[derive(Clone, PartialEq, Eq, Hash, Debug, Default)] +pub enum GraphScope { + Head, + /// HEAD plus its upstream — the default, matching VS Code. + #[default] + HeadAndUpstream, + All, + Refs(Vec), +} + +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct CommitPage { + pub commits: Vec, + /// Same length as `commits`. + pub rows: Vec, + pub max_lanes: Lane, + pub scope: GraphScope, + pub requested: usize, + /// git returned fewer than asked for, so this is the end of history. + pub complete: bool, + pub truncated_lanes: bool, + /// Lanes still open past the last row — drawn as fading stubs so a page + /// boundary does not read as a row of root commits. + pub open_lanes: Vec, +} + +// `LaneAlloc` — the append-only lane assigner these rows come out of — is +// defined below by the graph layout pass. It is append-only by design: a later +// page extends the graph without reflowing what is already on screen. diff --git a/crates/tty7-core/src/core/git.rs b/crates/tty7-core/src/core/git/mod.rs similarity index 75% rename from crates/tty7-core/src/core/git.rs rename to crates/tty7-core/src/core/git/mod.rs index eda88e62..deeca65a 100644 --- a/crates/tty7-core/src/core/git.rs +++ b/crates/tty7-core/src/core/git/mod.rs @@ -1,3 +1,19 @@ +//! Everything tty7 knows about git, split by question: +//! +//! - this file — how a `git` process is *run* (and its output re-assembled) +//! - [`status`] — what the working tree looks like right now +//! - [`diff`] — what a particular patch looks like +//! - [`log`] — what the history looks like, and how to lay it out in lanes +//! - [`ops`] — how to *change* the repository +//! +//! Nothing here depends on gpui: the headless `tty7-server` answers the same +//! 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 log; +pub mod ops; +pub mod status; + use std::io::{self, Read as _}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; @@ -94,6 +110,19 @@ pub fn git(host: &dyn Host, cwd: &Path, args: &[&str]) -> Option { } pub fn git_output(cwd: &Path, args: &[&str]) -> io::Result { + git_output_with_env(cwd, args, &[]) +} + +/// `git_output` plus extra environment. Network operations (`fetch`/`pull`/ +/// `push`) need to be told they have no terminal to prompt at; read paths must +/// *not* inherit that, so the two share a body rather than a config. +/// +/// A `None` value removes the variable instead of setting it. +pub fn git_output_with_env( + cwd: &Path, + args: &[&str], + env: &[(&str, Option<&str>)], +) -> io::Result { if !cwd.exists() { return Err(io::Error::new( io::ErrorKind::NotFound, @@ -104,12 +133,23 @@ pub fn git_output(cwd: &Path, args: &[&str]) -> io::Result { cmd.arg("-C") .arg(cwd) .args(args) + // Keeps `git status` from refreshing and writing back `.git/index`. + // Beyond the obvious (never dirty a repo just by looking at it), this is + // what stops the SCM panel's own probes from waking the `.git` watcher + // that schedules them — the read path is provably write-free. Do not + // drop it. .env("GIT_OPTIONAL_LOCKS", "0") .env_remove("GIT_DIR") .env_remove("GIT_WORK_TREE") .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); + for (key, value) in env { + match value { + Some(v) => cmd.env(key, v), + None => cmd.env_remove(key), + }; + } let out = crate::core::proc::hide_console(&mut cmd).output()?; Ok(Output { status: out.status.code(), @@ -210,6 +250,71 @@ impl LineSplitter { } } +/// Splits a byte stream on an arbitrary separator, handing out whole records. +/// +/// [`LineSplitter`]'s sibling, for the two git formats that are *not* newline +/// delimited: `--porcelain=v2 -z` (NUL) and `log --pretty` with an ASCII record +/// separator. Records come out as `&[u8]` rather than `&str` because a path in +/// a `-z` status is raw bytes and need not be UTF-8 at all — deciding what to +/// do about that belongs to the parser, not to the splitter. +pub struct RecordSplitter { + sep: u8, + tail: Vec, + dropped: usize, +} + +pub const MAX_RECORD: usize = 1024 * 1024; + +impl RecordSplitter { + pub fn new(sep: u8) -> RecordSplitter { + RecordSplitter { + sep, + tail: Vec::new(), + dropped: 0, + } + } + + pub fn push(&mut self, chunk: &[u8], mut on_record: impl FnMut(&[u8])) { + let mut rest = chunk; + while let Some(at) = rest.iter().position(|b| *b == self.sep) { + let (record, after) = rest.split_at(at); + if self.tail.is_empty() && self.dropped == 0 && record.len() <= MAX_RECORD { + on_record(record); + } else { + self.keep(record); + let joined = std::mem::take(&mut self.tail); + self.dropped = 0; + on_record(&joined); + } + rest = &after[1..]; + } + self.keep(rest); + } + + /// Emits a trailing record only if one was actually started. Unlike lines, + /// well-formed `-z` output ends *with* a separator, so the common case here + /// is emitting nothing. + pub fn finish(mut self, mut on_record: impl FnMut(&[u8])) { + if !self.tail.is_empty() { + let joined = std::mem::take(&mut self.tail); + on_record(&joined); + } + } + + /// How many bytes were discarded for overrunning [`MAX_RECORD`]. Non-zero + /// means the parse is incomplete and the caller should say so. + pub fn dropped(&self) -> usize { + self.dropped + } + + fn keep(&mut self, bytes: &[u8]) { + let room = MAX_RECORD.saturating_sub(self.tail.len()); + let take = room.min(bytes.len()); + self.tail.extend_from_slice(&bytes[..take]); + self.dropped += bytes.len() - take; + } +} + fn emit(line: &[u8], dropped: usize, on_line: &mut impl FnMut(&str)) { if dropped == 0 { on_line(&trim_cr(line)); diff --git a/crates/tty7-core/src/core/git/ops.rs b/crates/tty7-core/src/core/git/ops.rs new file mode 100644 index 00000000..1bb886f1 --- /dev/null +++ b/crates/tty7-core/src/core/git/ops.rs @@ -0,0 +1,221 @@ +//! Changing the repository. +//! +//! Every argv is built by [`GitOp::commands`], a pure function — that is where +//! the pathspec rules are enforced and where the tests point. `run_op` only +//! executes what it is handed and turns a failure into something the UI can +//! act on. +//! +//! Confirmation of destructive operations belongs to the UI, not here: +//! `run_op` has to stay callable from a flow that already confirmed once, and +//! from a test. What this module offers instead is [`GitOp::destructive`], the +//! policy datum the UI gates on. + +use std::path::PathBuf; + +/// One argv can only carry so many paths before it hits `E2BIG` (~256 KiB on +/// macOS), so a big stage is split into several calls. +pub const MAX_PATHSPECS_PER_CALL: usize = 200; + +/// Long enough for a push over a slow link. Only applied to network operations +/// — the local path has no deadline at all. +pub const GIT_NETWORK_DEADLINE: std::time::Duration = std::time::Duration::from_secs(600); + +/// What the user stands to lose. Purely advisory data for the UI's gate. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Destructive { + LosesWorktreeEdits, + LosesUntrackedFiles, + LosesCommits, + RewritesHistory, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum ResetMode { + Soft, + Mixed, + Hard, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum PullMode { + FfOnly, + Rebase, + Merge, +} + +#[derive(Clone, PartialEq, Eq, Debug)] +pub enum GitOp { + Stage { + paths: Vec, + }, + StageAll, + Unstage { + paths: Vec, + }, + UnstageAll, + /// `git checkout --` on tracked files. + DiscardWorktree { + paths: Vec, + }, + /// `git clean` on untracked ones. + DiscardUntracked { + paths: Vec, + directories: bool, + }, + Commit { + message: String, + amend: bool, + signoff: bool, + no_verify: bool, + /// Stage every tracked change first (`-a`), for "Commit All". + all: bool, + }, + CheckoutBranch { + name: String, + }, + CheckoutDetached { + rev: String, + }, + CreateBranch { + name: String, + start: Option, + checkout: bool, + }, + DeleteBranch { + name: String, + force: bool, + }, + CherryPick { + rev: String, + mainline: bool, + no_commit: bool, + }, + Revert { + rev: String, + mainline: bool, + }, + Reset { + rev: String, + mode: ResetMode, + }, + Stash { + message: Option, + include_untracked: bool, + }, + Fetch { + remote: Option, + prune: bool, + }, + Pull { + mode: PullMode, + }, + Push { + remote: String, + branch: String, + /// First push of a new branch: `-u`. + set_upstream: bool, + force_with_lease: bool, + }, +} + +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct GitOpOutcome { + pub op: &'static str, + pub stdout: String, + /// Non-empty on success too — remote hints and hook output land here. + pub stderr: String, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum GitOpErrorKind { + NotARepo, + /// The path is not UTF-8 and so cannot be sent as a pathspec. + UnrepresentablePath, + InvalidArgument, + DirtyWorktree, + Conflict, + NothingToCommit, + HookRejected, + LockHeld, + AuthRequired, + NetworkUnreachable, + NonFastForward, + Timeout, + Spawn, + Other, +} + +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct GitOpError { + pub op: &'static str, + pub kind: GitOpErrorKind, + /// One line for the notification. + pub message: String, + /// Full stderr, for the details disclosure. + pub detail: String, + /// The argv that failed. Authentication has no answer inside a GUI, so the + /// notification offers to re-run this in a pane — tty7 is a terminal, and a + /// real tty can take a password, a hardware key, or a host-key prompt. + pub rerun_argv: Vec, + pub cwd: PathBuf, +} + +impl GitOp { + /// The stable short name used in errors and telemetry. + pub fn label(&self) -> &'static str { + match self { + GitOp::Stage { .. } | GitOp::StageAll => "stage", + GitOp::Unstage { .. } | GitOp::UnstageAll => "unstage", + GitOp::DiscardWorktree { .. } | GitOp::DiscardUntracked { .. } => "discard", + GitOp::Commit { .. } => "commit", + GitOp::CheckoutBranch { .. } | GitOp::CheckoutDetached { .. } => "checkout", + GitOp::CreateBranch { .. } => "branch", + GitOp::DeleteBranch { .. } => "branch-delete", + GitOp::CherryPick { .. } => "cherry-pick", + GitOp::Revert { .. } => "revert", + GitOp::Reset { .. } => "reset", + GitOp::Stash { .. } => "stash", + GitOp::Fetch { .. } => "fetch", + GitOp::Pull { .. } => "pull", + GitOp::Push { .. } => "push", + } + } + + pub fn destructive(&self) -> Option { + Some(match self { + GitOp::DiscardWorktree { .. } => Destructive::LosesWorktreeEdits, + GitOp::DiscardUntracked { .. } => Destructive::LosesUntrackedFiles, + GitOp::DeleteBranch { .. } => Destructive::LosesCommits, + GitOp::Reset { + mode: ResetMode::Hard, + .. + } => Destructive::LosesWorktreeEdits, + GitOp::Commit { amend: true, .. } => Destructive::RewritesHistory, + GitOp::Push { + force_with_lease: true, + .. + } => Destructive::RewritesHistory, + _ => return None, + }) + } + + /// Whether this reaches the network, and so needs the long deadline. + pub fn is_network(&self) -> bool { + matches!( + self, + GitOp::Fetch { .. } | GitOp::Pull { .. } | GitOp::Push { .. } + ) + } + + /// Every path this operation names, for validation and for deciding + /// which caches to invalidate. + pub fn paths(&self) -> &[super::status::RepoPath] { + match self { + GitOp::Stage { paths } + | GitOp::Unstage { paths } + | GitOp::DiscardWorktree { paths } + | GitOp::DiscardUntracked { paths, .. } => paths, + _ => &[], + } + } +} diff --git a/crates/tty7-core/src/core/git/status.rs b/crates/tty7-core/src/core/git/status.rs new file mode 100644 index 00000000..45dd1e2b --- /dev/null +++ b/crates/tty7-core/src/core/git/status.rs @@ -0,0 +1,427 @@ +//! What the working tree looks like right now — the model behind the source +//! control panel, the file tree's decorations, and every button that is only +//! enabled for some file states. +//! +//! One `git status --porcelain=v2 --branch -z` answers all of it. That format +//! is the only one that carries the staged and unstaged halves *separately* +//! (the `XY` pair), a rename's old path, unmerged stages, submodule sub-state, +//! and the branch header — getting the same picture out of `git diff` takes +//! four commands and a consistency window between them. +//! +//! The types live here rather than next to the parser because `ops` builds +//! commands out of them (`HeadState` decides how to unstage) and the GUI reads +//! them; the parser is just one producer. + +use std::collections::HashMap; +use std::path::PathBuf; + +/// A path relative to the repository root, always `/`-separated. +/// +/// `-z` hands out raw bytes, and on Linux a path need not be UTF-8. When it is +/// not, `text` is the lossy rendering and `lossy` is set: such an entry can be +/// *shown* but never *acted on*, because `Host::git` takes `&[&str]` and the +/// control protocol carries args as `String` — the original bytes cannot reach +/// the far side. Callers must treat `pathspec() == None` as "read only". +#[derive(Clone, PartialEq, Eq, Hash, Debug)] +pub struct RepoPath { + pub text: String, + pub lossy: bool, +} + +impl RepoPath { + pub fn from_bytes(bytes: &[u8]) -> RepoPath { + match std::str::from_utf8(bytes) { + Ok(text) => RepoPath { + text: text.to_string(), + lossy: false, + }, + Err(_) => RepoPath { + text: String::from_utf8_lossy(bytes).into_owned(), + lossy: true, + }, + } + } + + pub fn as_str(&self) -> &str { + &self.text + } + + /// The pathspec to hand git, or `None` if this path cannot be represented. + /// + /// `:(literal)` is not decoration: without it git globs the pathspec, so a + /// file actually named `a[b].txt` or `foo*` would not match itself. Callers + /// must additionally put a `--` ahead of the list so a file named `HEAD` or + /// `-f` is not read as a rev or an option. + pub fn pathspec(&self) -> Option { + (!self.lossy).then(|| format!(":(literal){}", self.text)) + } + + pub fn file_name(&self) -> &str { + match self.text.rsplit_once('/') { + Some((_, name)) => name, + None => &self.text, + } + } + + pub fn parent(&self) -> &str { + match self.text.rsplit_once('/') { + Some((dir, _)) => dir, + None => "", + } + } +} + +/// One half of porcelain v2's `XY` pair. +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub enum ChangeCode { + None, + Modified, + TypeChanged, + Added, + Deleted, + Renamed, + Copied, + Unmerged, +} + +impl ChangeCode { + pub fn from_byte(b: u8) -> Option { + Some(match b { + b'.' | b' ' => ChangeCode::None, + b'M' => ChangeCode::Modified, + b'T' => ChangeCode::TypeChanged, + b'A' => ChangeCode::Added, + b'D' => ChangeCode::Deleted, + b'R' => ChangeCode::Renamed, + b'C' => ChangeCode::Copied, + b'U' => ChangeCode::Unmerged, + _ => return None, + }) + } + + /// The single character the UI shows in its 14px status column. + pub fn letter(self) -> char { + match self { + ChangeCode::None => ' ', + ChangeCode::Modified => 'M', + ChangeCode::TypeChanged => 'T', + ChangeCode::Added => 'A', + ChangeCode::Deleted => 'D', + ChangeCode::Renamed => 'R', + ChangeCode::Copied => 'C', + ChangeCode::Unmerged => 'U', + } + } + + pub fn is_change(self) -> bool { + self != ChangeCode::None + } +} + +/// The seven `XY` pairs porcelain v2 reports as unmerged (`u`) records. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum ConflictKind { + BothDeleted, + AddedByUs, + DeletedByThem, + AddedByThem, + DeletedByUs, + BothAdded, + BothModified, +} + +impl ConflictKind { + pub fn from_xy(x: u8, y: u8) -> Option { + Some(match (x, y) { + (b'D', b'D') => ConflictKind::BothDeleted, + (b'A', b'U') => ConflictKind::AddedByUs, + (b'U', b'D') => ConflictKind::DeletedByThem, + (b'U', b'A') => ConflictKind::AddedByThem, + (b'D', b'U') => ConflictKind::DeletedByUs, + (b'A', b'A') => ConflictKind::BothAdded, + (b'U', b'U') => ConflictKind::BothModified, + _ => return None, + }) + } + + /// Whether our side still has a file — decides if "open changes" can show + /// an ours/theirs diff or only one stage. + pub fn ours_exists(self) -> bool { + !matches!(self, ConflictKind::BothDeleted | ConflictKind::DeletedByUs) + } + + pub fn theirs_exists(self) -> bool { + !matches!( + self, + ConflictKind::BothDeleted | ConflictKind::DeletedByThem + ) + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub struct SubmoduleState { + pub commit_changed: bool, + pub modified_content: bool, + pub has_untracked: bool, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum EntryKind { + Tracked, + Unmerged, + Untracked, + Ignored, +} + +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct StatusEntry { + pub path: RepoPath, + /// Only set on rename/copy records, and it is the *old* path. + pub orig_path: Option, + /// `X` — HEAD against the index, i.e. what is staged. + pub index: ChangeCode, + /// `Y` — the index against the working tree, i.e. what is not staged. + pub worktree: ChangeCode, + pub kind: EntryKind, + /// `None` when the entry is not a submodule. + pub submodule: Option, + /// Similarity score from `R` / `C`, 0..=100. + pub rename_score: Option, + /// Always `Some` when `kind == Unmerged`. + pub conflict: Option, +} + +impl StatusEntry { + pub fn is_staged(&self) -> bool { + self.index.is_change() && self.conflict.is_none() + } + + /// A file can be both staged and unstaged at once (`XY == "MM"`) and then + /// appears in both groups — which is exactly what VS Code shows. + pub fn is_unstaged(&self) -> bool { + self.worktree.is_change() && self.conflict.is_none() + } + + pub fn is_untracked(&self) -> bool { + matches!(self.kind, EntryKind::Untracked) + } + + pub fn is_conflicted(&self) -> bool { + self.conflict.is_some() + } + + /// How this entry should be decorated wherever a single status is shown + /// (file tree, commit detail): the worse of its two halves. + pub fn deco(&self) -> DecoStatus { + if self.is_conflicted() { + return DecoStatus::Conflict; + } + if self.is_untracked() { + return DecoStatus::Untracked; + } + let worse = if code_rank(self.worktree) >= code_rank(self.index) { + self.worktree + } else { + self.index + }; + match worse { + ChangeCode::Deleted => DecoStatus::Deleted, + ChangeCode::Added => DecoStatus::Added, + ChangeCode::Renamed | ChangeCode::Copied => DecoStatus::Renamed, + ChangeCode::Unmerged => DecoStatus::Conflict, + _ => DecoStatus::Modified, + } + } +} + +fn code_rank(code: ChangeCode) -> u8 { + match code { + ChangeCode::None => 0, + ChangeCode::Modified | ChangeCode::TypeChanged => 1, + ChangeCode::Copied => 2, + ChangeCode::Renamed => 3, + ChangeCode::Added => 4, + ChangeCode::Deleted => 5, + ChangeCode::Unmerged => 6, + } +} + +#[derive(Clone, PartialEq, Eq, Debug)] +pub enum HeadState { + /// `# branch.oid (initial)` — the repository has no commits yet. + Unborn { + branch: String, + }, + Detached { + oid: String, + }, + Branch { + name: String, + oid: String, + }, +} + +impl HeadState { + /// What the chrome shows: a branch name, or a short sha when detached. + pub fn label(&self) -> String { + match self { + HeadState::Unborn { branch } => branch.clone(), + HeadState::Detached { oid } => oid.chars().take(7).collect(), + HeadState::Branch { name, .. } => name.clone(), + } + } + + /// `false` before the first commit, which is the one case where + /// `git reset HEAD -- ` fails outright. + pub fn has_commits(&self) -> bool { + !matches!(self, HeadState::Unborn { .. }) + } +} + +/// A sequencer operation left half-finished in the repository. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum RepoOperation { + Merge, + Rebase, + RebaseInteractive, + CherryPick, + Revert, + Bisect, + Am, +} + +/// Entries past this are dropped; `total_entries` still reports the real count +/// so the panel can say so instead of quietly showing a short list. +pub const MAX_STATUS_ENTRIES: usize = 10_000; + +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct WorkingTreeStatus { + /// This worktree's toplevel. + pub root: PathBuf, + /// The shared repository home — differs from `root` inside a linked + /// worktree. Same rule `core::git::probe` already uses to group tabs. + pub home: PathBuf, + pub head: HeadState, + pub upstream: Option, + pub ahead_behind: Option<(u32, u32)>, + pub entries: Vec, + pub total_entries: usize, + pub truncated: bool, + pub stash_count: u32, + pub operation: Option, + /// `.git/MERGE_MSG` or `SQUASH_MSG`, to pre-fill the commit box mid-merge. + pub prefilled_message: Option, +} + +impl WorkingTreeStatus { + pub fn staged(&self) -> impl Iterator { + self.entries.iter().filter(|e| e.is_staged()) + } + + pub fn unstaged(&self) -> impl Iterator { + self.entries + .iter() + .filter(|e| e.is_unstaged() && !e.is_untracked()) + } + + pub fn untracked(&self) -> impl Iterator { + self.entries.iter().filter(|e| e.is_untracked()) + } + + pub fn conflicts(&self) -> impl Iterator { + self.entries.iter().filter(|e| e.is_conflicted()) + } + + pub fn is_clean(&self) -> bool { + self.entries.is_empty() + } +} + +/// How a path is decorated wherever one status has to stand for a file. +/// +/// `Ord` is display precedence, lowest to highest: a directory takes the max of +/// everything beneath it, so one conflict anywhere colours the whole path up to +/// the root. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)] +pub enum DecoStatus { + Ignored, + Untracked, + Added, + Modified, + Renamed, + Deleted, + Conflict, +} + +/// Beyond this many entries the per-file map is dropped and only directories +/// stay decorated — a `node_modules` that slipped past `.gitignore` should slow +/// nothing down. +pub const MAX_DECORATED_FILES: usize = 5_000; + +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub struct DirRollup { + pub changed: bool, + pub conflict: bool, +} + +impl DirRollup { + pub fn merge(&mut self, status: DecoStatus) { + match status { + DecoStatus::Ignored => {} + DecoStatus::Conflict => { + self.conflict = true; + self.changed = true; + } + _ => self.changed = true, + } + } +} + +/// Repo-root-relative lookup for the file tree, built once per status refresh. +/// +/// Cost is O(changed paths × depth) — independent of how big the tree is — +/// and both lookups are a single hash probe, so a row can ask during render. +#[derive(Clone, Debug, Default)] +pub struct StatusIndex { + pub root: PathBuf, + files: HashMap, + dirs: HashMap, + /// Set when `files` was dropped for exceeding [`MAX_DECORATED_FILES`]. + pub files_dropped: bool, +} + +impl StatusIndex { + pub fn file(&self, repo_rel: &str) -> Option { + self.files.get(repo_rel).copied() + } + + pub fn dir(&self, repo_rel: &str) -> Option { + self.dirs.get(repo_rel).copied() + } + + pub fn is_empty(&self) -> bool { + self.files.is_empty() && self.dirs.is_empty() + } + + /// Insert one path and roll it up through every ancestor. Exposed so the + /// builder and its tests share exactly one definition of the walk. + pub fn insert(&mut self, repo_rel: &str, status: DecoStatus) { + self.files + .entry(repo_rel.to_string()) + .and_modify(|slot| *slot = (*slot).max(status)) + .or_insert(status); + let mut cut = repo_rel; + while let Some((parent, _)) = cut.rsplit_once('/') { + self.dirs + .entry(parent.to_string()) + .or_default() + .merge(status); + cut = parent; + } + } + + pub fn drop_files(&mut self) { + self.files.clear(); + self.files_dropped = true; + } +} diff --git a/src/terminal/git_diff.rs b/src/terminal/git_diff.rs index ac82dcc4..ddd8cd4d 100644 --- a/src/terminal/git_diff.rs +++ b/src/terminal/git_diff.rs @@ -1,732 +1,6 @@ -use std::path::{Path, PathBuf}; - -use crate::terminal::git_status; -use crate::ui::host_ops::Host; - -pub const MAX_LINES_PER_FILE: usize = 2000; - -pub const MAX_TOTAL_LINES: usize = 20_000; - -pub const MAX_FILES_WITH_HUNKS: usize = 500; - -pub const AUTO_COLLAPSE_LINES: u32 = 400; - -pub const AUTO_COLLAPSE_TOTAL_LINES: usize = 8_000; - -pub const AUTO_COLLAPSE_TOTAL_FILES: usize = 100; - -pub const MAX_RENDERED_FILES: usize = 300; - -pub const MAX_UNTRACKED: usize = 500; - -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum Truncation { - PerFile, - Budget, -} - -#[derive(Clone, PartialEq, Eq, Debug, Default)] -pub struct DiffSnapshot { - pub root: PathBuf, - pub branch: String, - pub files: Vec, - pub untracked: Vec, - pub untracked_total: usize, - pub read_failed: bool, -} - -impl DiffSnapshot { - pub fn totals(&self) -> (u32, u32) { - self.files - .iter() - .fold((0, 0), |(a, r), f| (a + f.added, r + f.removed)) - } - - pub fn untracked_count(&self) -> usize { - self.untracked_total.max(self.untracked.len()) - } - - pub fn stats(&self) -> DiffStats { - let mut added = 0u32; - let mut removed = 0u32; - let mut retained_lines = 0usize; - let mut budget_exhausted = false; - let mut per_file_truncated = false; - for file in &self.files { - added += file.added; - removed += file.removed; - retained_lines += file.hunks.iter().map(|h| h.lines.len()).sum::(); - match file.truncated { - Some(Truncation::Budget) => budget_exhausted = true, - Some(Truncation::PerFile) => per_file_truncated = true, - None => {} - } - } - let untracked_count = self.untracked_count(); - DiffStats { - totals: (added, removed), - retained_lines, - untracked_count, - oversized: self.files.len() > AUTO_COLLAPSE_TOTAL_FILES - || retained_lines > AUTO_COLLAPSE_TOTAL_LINES, - budget_exhausted, - per_file_truncated, - } - } -} - -#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] -pub struct DiffStats { - pub totals: (u32, u32), - pub retained_lines: usize, - pub untracked_count: usize, - pub oversized: bool, - pub budget_exhausted: bool, - pub per_file_truncated: bool, -} - -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum FileStatus { - Added, - Modified, - Deleted, - Renamed, -} - -#[derive(Clone, PartialEq, Eq, Debug)] -pub struct FileDiff { - pub path: String, - pub old_path: Option, - pub status: FileStatus, - pub added: u32, - pub removed: u32, - pub binary: bool, - pub truncated: Option, - pub hunks: Vec, -} - -#[derive(Clone, PartialEq, Eq, Debug)] -pub struct Hunk { - pub header: String, - pub lines: Vec, -} - -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum LineKind { - Context, - Added, - Removed, -} - -#[derive(Clone, PartialEq, Eq, Debug)] -pub struct DiffLine { - pub kind: LineKind, - pub old_no: Option, - pub new_no: Option, - pub text: String, -} - -pub fn probe(host: &dyn Host, cwd: &Path) -> Option { - let root = git_status::git(host, cwd, &["rev-parse", "--show-toplevel"])?; - let root = PathBuf::from(root.trim_end_matches(['\n', '\r'])); - let branch = git_status::branch_name(host, cwd)?; - let mut parser = DiffParser::default(); - let diffed = host.git_lines( - cwd, - &["diff", "--no-color", "--no-ext-diff", "-M", "HEAD"], - &mut |line| parser.push_line(line), - ); - let files = match diffed { - Ok(Some(0)) => parser.finish(), - _ => Vec::new(), - }; - let mut untracked: Vec = Vec::new(); - let mut untracked_total = 0usize; - let listed = host.git_lines( - cwd, - &["ls-files", "--others", "--exclude-standard", "--full-name"], - &mut |line| { - untracked_total += 1; - if untracked.len() < MAX_UNTRACKED { - untracked.push(line.to_string()); - } - }, - ); - if !matches!(listed, Ok(Some(0))) { - untracked.clear(); - untracked_total = 0; - } - Some(DiffSnapshot { - root, - branch, - files, - untracked, - untracked_total, - read_failed: !matches!(diffed, Ok(Some(0))) || !matches!(listed, Ok(Some(0))), - }) -} - -#[cfg(test)] -pub fn parse_unified(out: &str) -> Vec { - let mut parser = DiffParser::default(); - for line in out.lines() { - parser.push_line(line); - } - parser.finish() -} - -#[derive(Default)] -pub struct DiffParser { - files: Vec, - old_no: u32, - new_no: u32, - file_lines: usize, - total_lines: usize, - files_with_hunks: usize, - in_hunk: bool, -} - -impl DiffParser { - pub fn push_line(&mut self, line: &str) { - if let Some(rest) = line.strip_prefix("diff --git ") { - let (old_p, new_p) = parse_git_header_paths(rest); - self.files.push(FileDiff { - path: new_p.clone(), - old_path: (old_p != new_p).then_some(old_p), - status: FileStatus::Modified, - added: 0, - removed: 0, - binary: false, - truncated: None, - hunks: Vec::new(), - }); - self.file_lines = 0; - self.in_hunk = false; - return; - } - let Some(file) = self.files.last_mut() else { - return; - }; - if line.starts_with("new file mode") { - file.status = FileStatus::Added; - return; - } - if line.starts_with("deleted file mode") { - file.status = FileStatus::Deleted; - return; - } - if line.starts_with("rename from ") { - file.status = FileStatus::Renamed; - return; - } - if line.starts_with("Binary files ") || line.starts_with("GIT binary patch") { - file.binary = true; - return; - } - if !self.in_hunk - && (line.starts_with("--- ") || line.starts_with("+++ ") || !is_hunk_line(line)) - && !line.starts_with("@@") - { - return; - } - if line.starts_with("@@") { - self.in_hunk = true; - if file.truncated.is_some() { - return; - } - let first_hunk = file.hunks.is_empty(); - if (first_hunk && self.files_with_hunks >= MAX_FILES_WITH_HUNKS) - || self.total_lines >= MAX_TOTAL_LINES - { - file.truncated = Some(Truncation::Budget); - return; - } - if first_hunk { - self.files_with_hunks += 1; - } - let (o, n) = parse_hunk_starts(line).unwrap_or((0, 0)); - self.old_no = o; - self.new_no = n; - file.hunks.push(Hunk { - header: line.to_string(), - lines: Vec::new(), - }); - return; - } - if !self.in_hunk { - return; - } - let (kind, text) = match line.as_bytes().first() { - Some(b'+') => (LineKind::Added, &line[1..]), - Some(b'-') => (LineKind::Removed, &line[1..]), - Some(b' ') => (LineKind::Context, &line[1..]), - _ => return, - }; - match kind { - LineKind::Added => file.added += 1, - LineKind::Removed => file.removed += 1, - LineKind::Context => {} - } - if file.truncated.is_some() { - return; - } - self.file_lines += 1; - if self.file_lines > MAX_LINES_PER_FILE { - file.truncated = Some(Truncation::PerFile); - return; - } - if self.total_lines >= MAX_TOTAL_LINES { - file.truncated = Some(Truncation::Budget); - return; - } - let Some(hunk) = file.hunks.last_mut() else { - return; - }; - let (o, n) = match kind { - LineKind::Added => { - let n = self.new_no; - self.new_no += 1; - (None, Some(n)) - } - LineKind::Removed => { - let o = self.old_no; - self.old_no += 1; - (Some(o), None) - } - LineKind::Context => { - let (o, n) = (self.old_no, self.new_no); - self.old_no += 1; - self.new_no += 1; - (Some(o), Some(n)) - } - }; - hunk.lines.push(DiffLine { - kind, - old_no: o, - new_no: n, - text: text.to_string(), - }); - self.total_lines += 1; - } - - pub fn finish(self) -> Vec { - self.files - } -} - -fn is_hunk_line(line: &str) -> bool { - matches!(line.as_bytes().first(), Some(b'+' | b'-' | b' ' | b'\\')) || line.is_empty() -} - -fn parse_git_header_paths(rest: &str) -> (String, String) { - if rest.starts_with('"') { - let parts: Vec = parse_quoted_pair(rest); - if parts.len() == 2 { - return (strip_prefix_ab(&parts[0]), strip_prefix_ab(&parts[1])); - } - } - if let Some(idx) = rest.rfind(" b/") { - let old = &rest[..idx]; - let new = &rest[idx + 1..]; - return (strip_prefix_ab(old), strip_prefix_ab(new)); - } - (rest.to_string(), rest.to_string()) -} - -fn parse_quoted_pair(s: &str) -> Vec { - let mut parts = Vec::new(); - let mut cur = String::new(); - let mut in_quote = false; - let mut escaped = false; - for ch in s.chars() { - if escaped { - cur.push(ch); - escaped = false; - continue; - } - match ch { - '\\' if in_quote => escaped = true, - '"' => { - if in_quote { - parts.push(std::mem::take(&mut cur)); - } - in_quote = !in_quote; - } - _ if in_quote => cur.push(ch), - _ => {} - } - } - parts -} - -fn strip_prefix_ab(p: &str) -> String { - p.strip_prefix("a/") - .or_else(|| p.strip_prefix("b/")) - .unwrap_or(p) - .to_string() -} - -fn parse_hunk_starts(line: &str) -> Option<(u32, u32)> { - let rest = line.strip_prefix("@@ -")?; - let (old_part, rest) = rest.split_once(" +")?; - let (new_part, _) = rest.split_once(" @@")?; - let old = old_part.split(',').next()?.parse().ok()?; - let new = new_part.split(',').next()?.parse().ok()?; - Some((old, new)) -} - -#[cfg(test)] -mod tests { - use super::*; - - const SAMPLE: &str = "\ -diff --git a/src/main.rs b/src/main.rs -index 1111111..2222222 100644 ---- a/src/main.rs -+++ b/src/main.rs -@@ -10,4 +10,5 @@ fn main() { - let a = 1; --let b = old(); -+let b = new(); -+let c = 3; - done(); -diff --git a/docs/new.md b/docs/new.md -new file mode 100644 -index 0000000..3333333 ---- /dev/null -+++ b/docs/new.md -@@ -0,0 +1,2 @@ -+hello -+world -diff --git a/gone.txt b/gone.txt -deleted file mode 100644 -index 4444444..0000000 ---- a/gone.txt -+++ /dev/null -@@ -1,1 +0,0 @@ --bye -diff --git a/img.png b/img.png -index 5555555..6666666 100644 -Binary files a/img.png and b/img.png differ -"; - - #[test] - fn parses_the_four_file_shapes() { - let files = parse_unified(SAMPLE); - assert_eq!(files.len(), 4); - - let m = &files[0]; - assert_eq!(m.path, "src/main.rs"); - assert_eq!(m.status, FileStatus::Modified); - assert_eq!((m.added, m.removed), (2, 1)); - assert_eq!(m.hunks.len(), 1); - assert_eq!(m.hunks[0].header, "@@ -10,4 +10,5 @@ fn main() {"); - let lines = &m.hunks[0].lines; - assert_eq!(lines.len(), 5); - assert_eq!((lines[0].old_no, lines[0].new_no), (Some(10), Some(10))); - assert_eq!(lines[1].kind, LineKind::Removed); - assert_eq!(lines[1].old_no, Some(11)); - assert_eq!(lines[1].new_no, None); - assert_eq!(lines[2].kind, LineKind::Added); - assert_eq!(lines[2].new_no, Some(11)); - assert_eq!(lines[3].new_no, Some(12)); - assert_eq!(lines[3].text, "let c = 3;"); - assert_eq!((lines[4].old_no, lines[4].new_no), (Some(12), Some(13))); - - let a = &files[1]; - assert_eq!(a.status, FileStatus::Added); - assert_eq!((a.added, a.removed), (2, 0)); - - let d = &files[2]; - assert_eq!(d.status, FileStatus::Deleted); - assert_eq!((d.added, d.removed), (0, 1)); - - let b = &files[3]; - assert!(b.binary); - assert!(b.hunks.is_empty()); - } - - #[test] - fn parses_renames() { - let out = "\ -diff --git a/old/name.rs b/new/name.rs -similarity index 100% -rename from old/name.rs -rename to new/name.rs -"; - let files = parse_unified(out); - assert_eq!(files.len(), 1); - assert_eq!(files[0].status, FileStatus::Renamed); - assert_eq!(files[0].path, "new/name.rs"); - assert_eq!(files[0].old_path.as_deref(), Some("old/name.rs")); - assert_eq!((files[0].added, files[0].removed), (0, 0)); - } - - #[test] - fn parses_quoted_paths() { - let out = "diff --git \"a/has space.txt\" \"b/has space.txt\"\n"; - let files = parse_unified(out); - assert_eq!(files[0].path, "has space.txt"); - assert_eq!(files[0].old_path, None); - } - - #[test] - fn triple_dash_content_line_is_kept() { - let out = "\ -diff --git a/x.md b/x.md -index 1111111..2222222 100644 ---- a/x.md -+++ b/x.md -@@ -1,2 +1,1 @@ - keep ----- a heading rule -"; - let files = parse_unified(out); - let lines = &files[0].hunks[0].lines; - assert_eq!(lines.len(), 2); - assert_eq!(lines[1].kind, LineKind::Removed); - assert_eq!(lines[1].text, "--- a heading rule"); - } - - #[test] - fn caps_lines_per_file_but_keeps_counting() { - let mut out = String::from( - "diff --git a/big.txt b/big.txt\nindex 1..2 100644\n--- a/big.txt\n+++ b/big.txt\n@@ -0,0 +1,3000 @@\n", - ); - for i in 0..3000 { - out.push_str(&format!("+line {i}\n")); - } - let files = parse_unified(&out); - assert_eq!(files[0].truncated, Some(Truncation::PerFile)); - assert_eq!(files[0].added, 3000); - let kept: usize = files[0].hunks.iter().map(|h| h.lines.len()).sum(); - assert_eq!(kept, MAX_LINES_PER_FILE); - } - - #[test] - fn skips_no_newline_marker() { - let out = "\ -diff --git a/x b/x -index 1..2 100644 ---- a/x -+++ b/x -@@ -1,1 +1,1 @@ --old -\\ No newline at end of file -+new -\\ No newline at end of file -"; - let files = parse_unified(out); - assert_eq!(files[0].hunks[0].lines.len(), 2); - assert_eq!((files[0].added, files[0].removed), (1, 1)); - } - - #[test] - fn snapshot_totals() { - let snap = DiffSnapshot { - files: parse_unified(SAMPLE), - ..Default::default() - }; - assert_eq!(snap.totals(), (4, 2)); - } - - fn many_files(files: usize, lines_each: usize) -> String { - let mut out = String::new(); - for f in 0..files { - out.push_str(&format!( - "diff --git a/f{f}.rs b/f{f}.rs\nindex 1..2 100644\n--- a/f{f}.rs\n+++ b/f{f}.rs\n@@ -0,0 +1,{lines_each} @@\n" - )); - for i in 0..lines_each { - out.push_str(&format!("+file {f} line {i}\n")); - } - } - out - } - - #[test] - fn repo_wide_budget_caps_retained_lines() { - let files = parse_unified(&many_files(300, 300)); - assert_eq!(files.len(), 300, "every file keeps its header row"); - let retained: usize = files - .iter() - .flat_map(|f| &f.hunks) - .map(|h| h.lines.len()) - .sum(); - assert!( - retained <= MAX_TOTAL_LINES, - "retained {retained} lines, budget is {MAX_TOTAL_LINES}" - ); - assert!( - files - .iter() - .any(|f| f.truncated == Some(Truncation::Budget)) - ); - } - - #[test] - fn repo_wide_budget_keeps_totals_exact() { - let snap = DiffSnapshot { - files: parse_unified(&many_files(300, 300)), - ..Default::default() - }; - assert_eq!(snap.totals(), (90_000, 0)); - assert!(snap.stats().budget_exhausted); - } - - #[test] - fn repo_wide_budget_caps_files_with_hunks() { - let files = parse_unified(&many_files(MAX_FILES_WITH_HUNKS + 50, 1)); - assert_eq!(files.len(), MAX_FILES_WITH_HUNKS + 50); - let with_hunks = files.iter().filter(|f| !f.hunks.is_empty()).count(); - assert_eq!(with_hunks, MAX_FILES_WITH_HUNKS); - assert_eq!( - files.iter().map(|f| f.added).sum::(), - (MAX_FILES_WITH_HUNKS + 50) as u32 - ); - assert_eq!(files.last().unwrap().truncated, Some(Truncation::Budget)); - } - - #[test] - fn small_diff_is_not_truncated() { - let snap = DiffSnapshot { - files: parse_unified(SAMPLE), - ..Default::default() - }; - assert!(snap.files.iter().all(|f| f.truncated.is_none())); - assert!(!snap.stats().oversized); - assert!(!snap.stats().budget_exhausted); - } - - #[test] - fn oversized_trips_on_files_or_lines() { - let by_files = DiffSnapshot { - files: parse_unified(&many_files(AUTO_COLLAPSE_TOTAL_FILES + 1, 1)), - ..Default::default() - }; - assert!(by_files.stats().oversized); - - let per_file = MAX_LINES_PER_FILE / 2; - let by_lines = DiffSnapshot { - files: parse_unified(&many_files( - AUTO_COLLAPSE_TOTAL_LINES / per_file + 1, - per_file, - )), - ..Default::default() - }; - assert!(by_lines.files.len() <= AUTO_COLLAPSE_TOTAL_FILES); - assert!(by_lines.stats().retained_lines > AUTO_COLLAPSE_TOTAL_LINES); - assert!(by_lines.stats().oversized); - } - - #[test] - fn truncated_file_counts_dash_prefixed_content() { - let mut out = many_files(MAX_FILES_WITH_HUNKS, 1); - out.push_str( - "diff --git a/late.md b/late.md\nindex 1..2 100644\n--- a/late.md\n+++ b/late.md\n@@ -1,2 +1,1 @@\n keep\n--- a heading rule\n", - ); - let files = parse_unified(&out); - let late = files.last().unwrap(); - assert_eq!(late.path, "late.md"); - assert_eq!(late.truncated, Some(Truncation::Budget)); - assert!(late.hunks.is_empty(), "no body kept past the file cap"); - assert_eq!((late.added, late.removed), (0, 1), "but the line counts"); - } - - #[test] - fn untracked_is_capped_but_counted() { - let mut untracked: Vec = Vec::new(); - let mut untracked_total = 0usize; - for i in 0..(MAX_UNTRACKED * 3) { - untracked_total += 1; - if untracked.len() < MAX_UNTRACKED { - untracked.push(format!("node_modules/p{i}/index.js")); - } - } - let snap = DiffSnapshot { - untracked, - untracked_total, - ..Default::default() - }; - assert_eq!(snap.untracked.len(), MAX_UNTRACKED, "retention is bounded"); - assert_eq!( - snap.untracked_count(), - MAX_UNTRACKED * 3, - "the count is not" - ); - } - - #[test] - #[ignore = "measurement, not an assertion"] - fn bench_stream_vs_buffer() { - use std::time::Instant; - use tty7_core::core::git::{LineSplitter, git_output, git_stream}; - - let here = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); - let args = ["log", "-p", "-n", "400", "--no-color"]; - - let t = Instant::now(); - let Ok(out) = git_output(here, &args) else { - println!("no git here; skipping"); - return; - }; - let read = t.elapsed(); - let resident = out.stdout.len(); - let t = Instant::now(); - let buffered_files = parse_unified(&String::from_utf8_lossy(&out.stdout)); - let buffered_parse = t.elapsed(); - println!( - "buffered: {resident} bytes resident, read {read:?}, parse {buffered_parse:?}, \ - {} files", - buffered_files.len() - ); - drop(out); - - let t = Instant::now(); - let mut parser = DiffParser::default(); - let mut split = LineSplitter::default(); - let mut peak = 0usize; - git_stream(here, &args, |chunk| { - peak = peak.max(chunk.len()); - split.push(chunk, |line| parser.push_line(line)); - true - }) - .unwrap(); - split.finish(|line| parser.push_line(line)); - let streamed = t.elapsed(); - let streamed_files = parser.finish(); - println!( - "streamed: peak transient chunk {peak} bytes (vs {resident} resident), \ - read+parse {streamed:?}, {} files", - streamed_files.len() - ); - assert_eq!(buffered_files.len(), streamed_files.len()); - } - - #[test] - #[ignore = "measurement, not an assertion"] - fn bench_parse_budget() { - use std::time::Instant; - let out = many_files(300, 300); - println!("input: {} bytes, 300 files × 300 lines", out.len()); - let t = Instant::now(); - let files = parse_unified(&out); - let elapsed = t.elapsed(); - let retained: usize = files - .iter() - .flat_map(|f| &f.hunks) - .map(|h| h.lines.len()) - .sum(); - let bytes: usize = files - .iter() - .flat_map(|f| &f.hunks) - .flat_map(|h| &h.lines) - .map(|l| l.text.capacity() + std::mem::size_of::()) - .sum(); - println!( - "parse {elapsed:?} → {retained} retained lines, ~{} KiB of DiffLine text \ - (unbudgeted would be 90000 lines / ~{} KiB)", - bytes / 1024, - 90_000 * (24 + std::mem::size_of::()) / 1024, - ); - } -} +//! The diff model moved into `tty7-core` (`core::git::diff`) so the headless +//! server and the GUI parse a patch with the same code. Nothing here has a gpui +//! dependency, and the SCM panel needs the same types the daemon does. +//! +//! This shim keeps every `crate::terminal::git_diff::…` path in the GUI working. +pub use tty7_core::core::git::diff::*; diff --git a/src/terminal/git_status.rs b/src/terminal/git_status.rs index f53fa4be..a52a1b7a 100644 --- a/src/terminal/git_status.rs +++ b/src/terminal/git_status.rs @@ -1,7 +1,7 @@ use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; -pub use crate::core::git::{GitStatus, RepoSnapshot, branch_name, git, probe}; +pub use crate::core::git::{GitStatus, RepoSnapshot, probe}; use crate::ui::host_ops::{ByHost, HostId, InFlight}; #[derive(Default)]