mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 00:02:23 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
@@ -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,
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,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)]
|
||||
|
||||
Reference in New Issue
Block a user