char/sorcery

static-files based git repo viewer

git clone https://git.t4t.associates/char/sorcery

Charlotte Somfix path-history smart query type (+ add a new obj frame type)5d40e97

main
6.5 KiB193 linesraw
1//! `git log -- path` with git's default history simplification: walk commits
2//! newest-first, and at each merge that is TREESAME to a parent follow only
3//! that parent, so the walk never leaves the side of a merge that touched the
4//! path. The walk state is just the frontier, so a page can be resumed from
5//! whatever it hands back.
6
7use std::cmp::Reverse;
8use std::collections::{BinaryHeap, HashMap, HashSet};
9
10use anyhow::Result;
11
12const TREE: u16 = 0o040000;
13
14#[derive(Clone, Copy, PartialEq, Eq)]
15struct Located {
16    oid: gix::ObjectId,
17    mode: u16,
18}
19
20pub struct Page {
21    /// Commits that changed the path, in walk order.
22    pub changes: Vec<gix::ObjectId>,
23    /// Where to resume; empty once history is exhausted.
24    pub frontier: Vec<gix::ObjectId>,
25}
26
27/// Walks from `frontier` until `limit` changes are found, `scan_limit` commits
28/// have been examined, or history is exhausted (empty returned frontier).
29pub fn page(
30    repo: &gix::Repository,
31    frontier: &[gix::ObjectId],
32    path: &[String],
33    limit: usize,
34    scan_limit: usize,
35) -> Result<Page> {
36    // committer-date order with FIFO ties, like git's commit_list_insert_by_date
37    let mut heap = BinaryHeap::new();
38    let mut seen = HashSet::new();
39    let mut located = HashMap::new();
40    let mut changes = Vec::new();
41    let mut scanned = 0;
42
43    let mut push = |heap: &mut BinaryHeap<_>, mut oid: gix::ObjectId| -> Result<()> {
44        let commit = loop {
45            let object = repo.find_object(oid)?;
46            match object.kind {
47                gix::object::Kind::Tag => oid = object.into_tag().target_id()?.detach(),
48                _ => break object.try_into_commit()?,
49            }
50        };
51        if seen.insert(oid) {
52            heap.push((commit.time()?.seconds, Reverse(seen.len()), oid));
53        }
54        Ok(())
55    };
56    for &oid in frontier {
57        push(&mut heap, oid)?;
58    }
59
60    while changes.len() < limit && scanned < scan_limit {
61        let Some((_, _, oid)) = heap.pop() else {
62            break;
63        };
64        scanned += 1;
65        let commit = repo.find_object(oid)?.into_commit();
66        let object = locate(repo, &mut located, commit.tree_id()?.detach(), path)?;
67        let parents = commit.parent_ids().map(|id| id.detach()).collect::<Vec<_>>();
68
69        let mut treesame = None;
70        for &parent in &parents {
71            let tree = repo.find_object(parent)?.try_into_commit()?.tree_id()?.detach();
72            if locate(repo, &mut located, tree, path)? == object {
73                treesame = Some(parent);
74                break;
75            }
76        }
77        match treesame {
78            Some(parent) => push(&mut heap, parent)?,
79            None => {
80                if parents.is_empty() && object.is_none() {
81                    continue;
82                }
83                changes.push(oid);
84                for parent in parents {
85                    push(&mut heap, parent)?;
86                }
87            }
88        }
89    }
90
91    Ok(Page {
92        changes,
93        // in walk order, so resuming re-pushes ties in the same order
94        frontier: heap.into_sorted_vec().into_iter().rev().map(|(_, _, oid)| oid).collect(),
95    })
96}
97
98/// The entry at `path` within `tree`, memoised per tree since consecutive
99/// commits share nearly all of theirs.
100fn locate(
101    repo: &gix::Repository,
102    memo: &mut HashMap<gix::ObjectId, Option<Located>>,
103    tree: gix::ObjectId,
104    path: &[String],
105) -> Result<Option<Located>> {
106    if let Some(&found) = memo.get(&tree) {
107        return Ok(found);
108    }
109    let mut found = Some(Located { oid: tree, mode: TREE });
110    for component in path {
111        let Some(Located { oid, mode: TREE }) = found else {
112            found = None;
113            break;
114        };
115        found = None;
116        for entry in repo.find_object(oid)?.try_into_tree()?.iter() {
117            let entry = entry?;
118            if entry.filename() == component.as_bytes() {
119                found = Some(Located { oid: entry.oid().to_owned(), mode: entry.mode().value() });
120                break;
121            }
122        }
123    }
124    memo.insert(tree, found);
125    Ok(found)
126}
127
128#[cfg(test)]
129mod tests {
130    use std::fs;
131
132    use anyhow::Result;
133
134    use crate::testutil::{TempDir, commit, git, init_repo};
135
136    use super::page;
137
138    /// A merge where the path changed on the side branch only, a merge that
139    /// changed it on both sides, and a deletion.
140    #[test]
141    fn matches_git_log_simplification() -> Result<()> {
142        let root = TempDir::new("history");
143        let repo = root.join("repo");
144        init_repo(&repo)?;
145        fs::create_dir(repo.join("src"))?;
146        fs::write(repo.join("src/a.txt"), "1")?;
147        fs::write(repo.join("other"), "x")?;
148        commit(&repo, "one")?;
149        git(&repo, &["checkout", "-qb", "side"])?;
150        fs::write(repo.join("src/a.txt"), "2")?;
151        commit(&repo, "two (side)")?;
152        git(&repo, &["checkout", "-q", "main"])?;
153        fs::write(repo.join("other"), "y")?;
154        commit(&repo, "unrelated")?;
155        git(&repo, &["merge", "-q", "--no-ff", "-m", "merge side", "side"])?;
156        git(&repo, &["checkout", "-qb", "conflict"])?;
157        fs::write(repo.join("src/a.txt"), "3")?;
158        commit(&repo, "three (conflict)")?;
159        git(&repo, &["checkout", "-q", "main"])?;
160        fs::write(repo.join("src/a.txt"), "4")?;
161        commit(&repo, "four")?;
162        git(&repo, &["merge", "-q", "conflict"]).ok();
163        fs::write(repo.join("src/a.txt"), "5")?;
164        commit(&repo, "resolve")?;
165        git(&repo, &["rm", "-q", "src/a.txt"])?;
166        commit(&repo, "delete")?;
167        let head = git(&repo, &["rev-parse", "HEAD"])?;
168        let expected = git(&repo, &["log", "--format=%H", "--", "src/a.txt"])?;
169        let expected = expected.lines().collect::<Vec<_>>();
170
171        let gix = gix::open(&repo)?;
172        let path = ["src".to_owned(), "a.txt".to_owned()];
173        let head = gix::ObjectId::from_hex(head.as_bytes())?;
174
175        let whole = page(&gix, &[head], &path, 100, 1000)?;
176        assert_eq!(
177            whole.changes.iter().map(ToString::to_string).collect::<Vec<_>>(),
178            expected,
179        );
180        assert!(whole.frontier.is_empty());
181
182        // resuming through the frontier reproduces the same sequence
183        let mut frontier = vec![head];
184        let mut paged = Vec::new();
185        while !frontier.is_empty() {
186            let result = page(&gix, &frontier, &path, 2, 2)?;
187            paged.extend(result.changes.iter().map(ToString::to_string));
188            frontier = result.frontier;
189        }
190        assert_eq!(paged, expected);
191        Ok(())
192    }
193}