refactor(git): split core::git into a module tree and move the diff model into it

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.
This commit is contained in:
l0ng-ai
2026-08-09 00:13:08 +08:00
parent 817447bd48
commit c3233bb39b
8 changed files with 1671 additions and 733 deletions
+6
View File
@@ -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
+732
View File
@@ -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<FileDiff>,
pub untracked: Vec<String>,
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::<usize>();
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<String>,
pub status: FileStatus,
pub added: u32,
pub removed: u32,
pub binary: bool,
pub truncated: Option<Truncation>,
pub hunks: Vec<Hunk>,
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct Hunk {
pub header: String,
pub lines: Vec<DiffLine>,
}
#[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<u32>,
pub new_no: Option<u32>,
pub text: String,
}
pub fn probe(host: &dyn Host, cwd: &Path) -> Option<DiffSnapshot> {
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<String> = 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<FileDiff> {
let mut parser = DiffParser::default();
for line in out.lines() {
parser.push_line(line);
}
parser.finish()
}
#[derive(Default)]
pub struct DiffParser {
files: Vec<FileDiff>,
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<FileDiff> {
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<String> = 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<String> {
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::<u32>(),
(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<String> = 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::<DiffLine>())
.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::<DiffLine>()) / 1024,
);
}
}
+173
View File
@@ -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<RefDeco>,
}
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<String>),
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct CommitPage {
pub commits: Vec<Commit>,
/// Same length as `commits`.
pub rows: Vec<GraphRow>,
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<Lane>,
}
// `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.
@@ -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<String> {
}
pub fn git_output(cwd: &Path, args: &[&str]) -> io::Result<Output> {
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<Output> {
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<Output> {
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<u8>,
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));
+221
View File
@@ -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<super::status::RepoPath>,
},
StageAll,
Unstage {
paths: Vec<super::status::RepoPath>,
},
UnstageAll,
/// `git checkout --` on tracked files.
DiscardWorktree {
paths: Vec<super::status::RepoPath>,
},
/// `git clean` on untracked ones.
DiscardUntracked {
paths: Vec<super::status::RepoPath>,
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<String>,
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<String>,
include_untracked: bool,
},
Fetch {
remote: Option<String>,
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<String>,
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<Destructive> {
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,
_ => &[],
}
}
}
+427
View File
@@ -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<String> {
(!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<ChangeCode> {
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<ConflictKind> {
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<RepoPath>,
/// `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<SubmoduleState>,
/// Similarity score from `R<score>` / `C<score>`, 0..=100.
pub rename_score: Option<u8>,
/// Always `Some` when `kind == Unmerged`.
pub conflict: Option<ConflictKind>,
}
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 -- <path>` 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<String>,
pub ahead_behind: Option<(u32, u32)>,
pub entries: Vec<StatusEntry>,
pub total_entries: usize,
pub truncated: bool,
pub stash_count: u32,
pub operation: Option<RepoOperation>,
/// `.git/MERGE_MSG` or `SQUASH_MSG`, to pre-fill the commit box mid-merge.
pub prefilled_message: Option<String>,
}
impl WorkingTreeStatus {
pub fn staged(&self) -> impl Iterator<Item = &StatusEntry> {
self.entries.iter().filter(|e| e.is_staged())
}
pub fn unstaged(&self) -> impl Iterator<Item = &StatusEntry> {
self.entries
.iter()
.filter(|e| e.is_unstaged() && !e.is_untracked())
}
pub fn untracked(&self) -> impl Iterator<Item = &StatusEntry> {
self.entries.iter().filter(|e| e.is_untracked())
}
pub fn conflicts(&self) -> impl Iterator<Item = &StatusEntry> {
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<String, DecoStatus>,
dirs: HashMap<String, DirRollup>,
/// Set when `files` was dropped for exceeding [`MAX_DECORATED_FILES`].
pub files_dropped: bool,
}
impl StatusIndex {
pub fn file(&self, repo_rel: &str) -> Option<DecoStatus> {
self.files.get(repo_rel).copied()
}
pub fn dir(&self, repo_rel: &str) -> Option<DirRollup> {
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;
}
}
+6 -732
View File
@@ -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<FileDiff>,
pub untracked: Vec<String>,
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::<usize>();
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<String>,
pub status: FileStatus,
pub added: u32,
pub removed: u32,
pub binary: bool,
pub truncated: Option<Truncation>,
pub hunks: Vec<Hunk>,
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct Hunk {
pub header: String,
pub lines: Vec<DiffLine>,
}
#[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<u32>,
pub new_no: Option<u32>,
pub text: String,
}
pub fn probe(host: &dyn Host, cwd: &Path) -> Option<DiffSnapshot> {
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<String> = 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<FileDiff> {
let mut parser = DiffParser::default();
for line in out.lines() {
parser.push_line(line);
}
parser.finish()
}
#[derive(Default)]
pub struct DiffParser {
files: Vec<FileDiff>,
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<FileDiff> {
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<String> = 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<String> {
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::<u32>(),
(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<String> = 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::<DiffLine>())
.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::<DiffLine>()) / 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::*;
+1 -1
View File
@@ -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)]