chadtmiller.com/knotless

Git hosted in your atproto account, on any stock PDS, fast enough for agents.

git clone knotless://chadtmiller.com/knotless

needs the knotless git helper. set it up

257 lines · 7.6 KB Raw History
//! 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);
    }
}