chadtmiller.com/knotless
Git hosted in your atproto account, on any stock PDS, fast enough for agents.
git clone knotless://chadtmiller.com/knotlessneeds the knotless git helper. set it up
//! A patch read into files and rows, so a page can show each line with its
//! old and new numbers rather than as the text git prints.
/// One file's part of a patch.
#[derive(Debug, Default, PartialEq)]
pub struct File {
/// Its path before, and after; the same unless it moved.
pub old: String,
pub new: String,
pub status: Status,
pub binary: bool,
pub rows: Vec<Row>,
pub added: usize,
pub removed: usize,
}
#[derive(Debug, Default, Clone, Copy, PartialEq)]
pub enum Status {
Added,
Deleted,
Renamed,
#[default]
Modified,
}
#[derive(Debug, PartialEq)]
pub struct Row {
pub kind: Kind,
pub old: Option<u32>,
pub new: Option<u32>,
/// The line without its `+`, `-` or space; a hunk's or note's whole line.
pub text: String,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Kind {
Hunk,
Context,
Add,
Del,
/// `\ No newline at end of file`.
Note,
}
impl Kind {
/// Whether the row is a line of the file, rather than about it.
pub fn is_line(self) -> bool {
matches!(self, Kind::Context | Kind::Add | Kind::Del)
}
}
/// `-12,3` or `+12`: where a hunk's side starts, and how many lines it has.
fn range(s: &str) -> Option<(u32, u32)> {
let (start, len) = s[1..].split_once(',').unwrap_or((&s[1..], "1"));
Some((start.parse().ok()?, len.parse().ok()?))
}
fn path(s: &str) -> String {
s.split_once('/')
.map_or(s, |(_, p)| p)
.trim_end_matches('\t')
.to_string()
}
/// Reads `git show --patch` or `git diff` output. A hunk's lines are counted
/// from its header, so a removed line that reads `-- x` is not taken for a
/// file header.
pub fn parse(patch: &str) -> Vec<File> {
let mut files: Vec<File> = Vec::new();
// Lines left in the current hunk, old and new.
let (mut old_left, mut new_left) = (0u32, 0u32);
let (mut old_at, mut new_at) = (0u32, 0u32);
for line in patch.lines() {
if old_left > 0 || new_left > 0 {
let Some(file) = files.last_mut() else { break };
let (kind, text) = match line.split_at_checked(1).unwrap_or((" ", "")) {
("+", text) => (Kind::Add, text),
("-", text) => (Kind::Del, text),
("\\", _) => (Kind::Note, line),
(_, text) => (Kind::Context, text),
};
let (old, new) = match kind {
Kind::Add => (None, Some(new_at)),
Kind::Del => (Some(old_at), None),
Kind::Context => (Some(old_at), Some(new_at)),
_ => (None, None),
};
if old.is_some() {
(old_at, old_left) = (old_at + 1, old_left.saturating_sub(1));
}
if new.is_some() {
(new_at, new_left) = (new_at + 1, new_left.saturating_sub(1));
}
file.added += (kind == Kind::Add) as usize;
file.removed += (kind == Kind::Del) as usize;
file.rows.push(Row {
kind,
old,
new,
text: text.to_string(),
});
continue;
}
if let Some(names) = line.strip_prefix("diff --git ") {
let (a, b) = names.split_once(" b/").unwrap_or((names, names));
files.push(File {
old: path(a),
new: b.to_string(),
..File::default()
});
continue;
}
let Some(file) = files.last_mut() else {
continue;
};
if line.starts_with("new file mode") {
file.status = Status::Added;
} else if line.starts_with("deleted file mode") {
file.status = Status::Deleted;
} else if let Some(p) = line.strip_prefix("rename from ") {
(file.old, file.status) = (p.to_string(), Status::Renamed);
} else if let Some(p) = line.strip_prefix("rename to ") {
file.new = p.to_string();
} else if line.starts_with("Binary files ") {
file.binary = true;
} else if let Some(p) = line.strip_prefix("--- ").filter(|p| *p != "/dev/null") {
file.old = path(p);
} else if let Some(p) = line.strip_prefix("+++ ").filter(|p| *p != "/dev/null") {
file.new = path(p);
} else if line.starts_with("@@ ") {
let mut sides = line.split(' ').skip(1);
let (Some((o, ol)), Some((n, nl))) =
(sides.next().and_then(range), sides.next().and_then(range))
else {
continue;
};
(old_at, old_left, new_at, new_left) = (o, ol, n, nl);
file.rows.push(Row {
kind: Kind::Hunk,
old: None,
new: None,
text: line.to_string(),
});
} else if line.starts_with('\\') {
file.rows.push(Row {
kind: Kind::Note,
old: None,
new: None,
text: line.to_string(),
});
}
}
files
}
#[cfg(test)]
mod tests {
use super::*;
fn shown(f: &File) -> Vec<String> {
f.rows
.iter()
.map(|r| {
let n = |x: Option<u32>| x.map(|x| x.to_string()).unwrap_or_default();
format!("{:?} {} {} {}", r.kind, n(r.old), n(r.new), r.text)
})
.collect()
}
#[test]
fn reads_a_patch() {
let patch = "\
diff --git a/src/a.rs b/src/a.rs
index 1..2 100644
--- a/src/a.rs
+++ b/src/a.rs
@@ -1,3 +1,3 @@ fn a()
keep
--- not a header
+++ nor this
end
@@ -9 +9,2 @@
-x
\\ No newline at end of file
+x
+y
diff --git a/new file.txt b/new file.txt
new file mode 100644
--- /dev/null
+++ b/new file.txt
@@ -0,0 +1 @@
+hi
diff --git a/gone b/gone
deleted file mode 100644
--- a/gone
+++ /dev/null
@@ -1 +0,0 @@
-bye
diff --git a/old.png b/new.png
similarity index 90%
rename from old.png
rename to new.png
Binary files a/old.png and b/new.png differ
diff --git a/run b/run
old mode 100644
new mode 100755
@@ broken @@
\\ stray
";
let files = parse(patch);
assert_eq!(files.len(), 5);
let a = &files[0];
assert_eq!(
(a.old.as_str(), a.new.as_str(), a.status),
("src/a.rs", "src/a.rs", Status::Modified)
);
assert_eq!((a.added, a.removed), (3, 2));
assert_eq!(
shown(a),
[
"Hunk @@ -1,3 +1,3 @@ fn a()",
"Context 1 1 keep",
"Del 2 -- not a header",
"Add 2 ++ nor this",
"Context 3 3 end",
"Hunk @@ -9 +9,2 @@",
"Del 9 x",
"Note \\ No newline at end of file",
"Add 9 x",
"Add 10 y",
]
);
assert_eq!(
(files[1].new.as_str(), files[1].status, files[1].added),
("new file.txt", Status::Added, 1)
);
assert_eq!(
(files[2].old.as_str(), files[2].status, files[2].removed),
("gone", Status::Deleted, 1)
);
let moved = &files[3];
assert_eq!(
(
moved.old.as_str(),
moved.new.as_str(),
moved.status,
moved.binary
),
("old.png", "new.png", Status::Renamed, true)
);
assert_eq!(shown(&files[4]), ["Note \\ stray"]);
assert!(Kind::Add.is_line() && !Kind::Hunk.is_line());
assert!(parse("index 1..2\n@@ -1 +1 @@\n x\n").is_empty());
assert_eq!(range("-3"), Some((3, 1)));
assert_eq!(range("+a,1"), None);
}
}