Skip to main content

grit_lib/
log_walk.rs

1//! Resumable HEAD log walks for bounded `grit log` paging.
2//!
3//! [`LogWalkCursor`] encodes the heap and visited sets of the date-order reachable walk
4//! so `--cursor` continues the same traversal instead of restarting from a single commit.
5
6use std::io::{Cursor, Read, Write};
7use std::ops::Range;
8use std::path::{Path, PathBuf};
9
10use crate::error::{Error, Result};
11use crate::merge_base::{ReachableWalkCommitterCutoffs, ReachableWalkSnapshot, ReachableWalkState};
12use crate::objects::{parse_commit, CommitData, ObjectId, OidHashMap};
13use crate::porcelain::log::CommitReuse;
14use crate::repo::Repository;
15use crate::revwalk::queries::{resolve_log_walk_tips_and_hide, MergeFilter, RevWalkListOptions};
16
17const CURSOR_PREFIX: &str = "lw1.";
18/// Repository-local short continuation token (`lw1.s.` + eight id chars).
19const SHORT_CURSOR_PREFIX: &str = "lw1.s.";
20const SHORT_CURSOR_ID_LEN: usize = 8;
21const MAGIC_V2: &[u8; 4] = b"GLW2";
22const MAGIC_V3: &[u8; 4] = b"GLW3";
23const NO_CUTOFF: i64 = i64::MIN;
24
25/// Tips, exclusions, and date cutoffs for a resumable date-order log walk.
26#[derive(Clone, Debug, PartialEq, Eq)]
27pub struct LogWalkQuery {
28    /// Positive walk tips (resolved commit oids).
29    pub tips: Vec<ObjectId>,
30    /// Hide tips whose ancestors are omitted from output.
31    pub hide: Vec<ObjectId>,
32    /// Committer-date filters applied during the walk.
33    pub cutoffs: ReachableWalkCommitterCutoffs,
34}
35
36impl LogWalkQuery {
37    /// Build a walk query when revision selection maps to default-order reachable walks.
38    ///
39    /// Returns [`None`] when `walk` uses options that require full `rev-list` (paths,
40    /// `--reverse`, `--first-parent`, parent-count filters, or `--skip`).
41    ///
42    /// # Errors
43    ///
44    /// Propagates rev-parse and ref errors from [`resolve_log_walk_tips_and_hide`].
45    pub fn from_rev_walk(
46        repo: &Repository,
47        tokens: &[&str],
48        walk: &RevWalkListOptions,
49    ) -> Result<Option<Self>> {
50        if walk.graph
51            || !walk.paths.is_empty()
52            || walk.reverse
53            || walk.first_parent
54            || walk.merges != MergeFilter::Any
55            || walk.skip > 0
56            || walk.commit_filter.as_ref().is_some_and(|f| f.is_active())
57            || walk.pickaxe.is_some()
58        {
59            return Ok(None);
60        }
61        let (tips, hide) = resolve_log_walk_tips_and_hide(repo, tokens, walk)?;
62        Ok(Some(Self {
63            tips,
64            hide,
65            cutoffs: ReachableWalkCommitterCutoffs {
66                since: walk.since,
67                until: walk.until,
68            },
69        }))
70    }
71}
72
73/// Opaque continuation token for paged `grit log` history walks.
74#[derive(Clone, Debug, PartialEq, Eq)]
75pub struct LogWalkCursor {
76    query: LogWalkQuery,
77    walk: ReachableWalkSnapshot,
78}
79
80/// One page of commits plus an optional continuation cursor.
81#[derive(Clone, Debug, PartialEq, Eq)]
82pub struct LogWalkPage {
83    /// Emitted commits, newest first.
84    pub commits: Vec<ObjectId>,
85    /// Cursor for the next page, or [`None`] when the walk is exhausted.
86    pub next: Option<LogWalkCursor>,
87    /// Commits from [`Self::commits`] that the walk already read from the object database.
88    /// Commits whose parents and dates came from the commit-graph are absent.
89    ///
90    /// Pass this to
91    /// [`load_commits_with_decorations_reusing`](crate::porcelain::log::load_commits_with_decorations_reusing)
92    /// to build output rows without reading those commits again.
93    pub commit_data: WalkedCommits,
94}
95
96/// Commit objects a log walk already read, kept as raw object bytes and parsed when taken.
97///
98/// The payloads share one buffer instead of a parsed [`CommitData`] (or allocation) per
99/// commit: a long page then costs one large block that goes back to the operating system
100/// when dropped, rather than thousands of small ones that raise the heap's high-water mark.
101#[derive(Clone, Debug, Default)]
102pub struct WalkedCommits {
103    bytes: Vec<u8>,
104    spans: OidHashMap<Range<usize>>,
105}
106
107impl WalkedCommits {
108    /// Empty set with room for the ids of `commits` commits.
109    pub fn with_capacity(commits: usize) -> Self {
110        Self {
111            bytes: Vec::new(),
112            spans: crate::objects::oid_hash_map_with_capacity(commits),
113        }
114    }
115
116    /// Keep a copy of the commit object payload `data` for `oid`.
117    pub fn insert(&mut self, oid: ObjectId, data: &[u8]) {
118        let start = self.bytes.len();
119        self.bytes.extend_from_slice(data);
120        self.spans.insert(oid, start..self.bytes.len());
121    }
122
123    /// Forget every commit not in `keep`.
124    pub fn retain(&mut self, keep: impl Fn(&ObjectId) -> bool) {
125        self.spans.retain(|oid, _| keep(oid));
126    }
127
128    /// Number of commits held.
129    #[must_use]
130    pub fn len(&self) -> usize {
131        self.spans.len()
132    }
133
134    /// Whether no commits are held.
135    #[must_use]
136    pub fn is_empty(&self) -> bool {
137        self.spans.is_empty()
138    }
139
140    /// Whether the commit `oid` is held.
141    #[must_use]
142    pub fn contains(&self, oid: &ObjectId) -> bool {
143        self.spans.contains_key(oid)
144    }
145
146    /// Ids of the commits held, in no particular order.
147    pub fn oids(&self) -> impl Iterator<Item = &ObjectId> + '_ {
148        self.spans.keys()
149    }
150
151    fn payload(&self, span: &Range<usize>) -> &[u8] {
152        self.bytes.get(span.clone()).unwrap_or_default()
153    }
154}
155
156/// Equal when both hold the same commits with the same payloads, however they are laid out.
157impl PartialEq for WalkedCommits {
158    fn eq(&self, other: &Self) -> bool {
159        self.spans.len() == other.spans.len()
160            && self.spans.iter().all(|(oid, span)| {
161                other
162                    .spans
163                    .get(oid)
164                    .is_some_and(|theirs| self.payload(span) == other.payload(theirs))
165            })
166    }
167}
168
169impl Eq for WalkedCommits {}
170
171impl CommitReuse for WalkedCommits {
172    /// Remove `oid` and parse it, exactly as [`parse_commit`] parses the object payload.
173    fn take_commit(&mut self, oid: &ObjectId) -> Option<Result<CommitData>> {
174        let span = self.spans.remove(oid)?;
175        Some(parse_commit(self.payload(&span)))
176    }
177
178    fn cached_subject_line(&self, oid: &ObjectId) -> Option<Result<String>> {
179        let span = self.spans.get(oid)?;
180        Some(crate::objects::read_commit_subject_line(self.payload(span)))
181    }
182}
183
184impl LogWalkCursor {
185    /// Revision tips, exclusions, and date cutoffs bound to this continuation token.
186    #[must_use]
187    pub fn query(&self) -> &LogWalkQuery {
188        &self.query
189    }
190
191    /// Start or resume a date-order walk for `query`, emitting up to `limit` commits.
192    ///
193    /// Pass `cursor` from a previous page's [`LogWalkPage::next`]. When `cursor` is
194    /// [`None`], a fresh walk begins from `query`.
195    ///
196    /// # Errors
197    ///
198    /// Returns [`Error::CorruptObject`] for an invalid cursor, when the query changed since
199    /// the cursor was issued, or when commit objects cannot be read.
200    pub fn step(
201        repo: &Repository,
202        query: &LogWalkQuery,
203        cursor: Option<&LogWalkCursor>,
204        limit: usize,
205    ) -> Result<LogWalkPage> {
206        if limit == 0 {
207            return Ok(LogWalkPage {
208                commits: Vec::new(),
209                next: cursor.cloned(),
210                commit_data: WalkedCommits::default(),
211            });
212        }
213
214        let resume = match cursor {
215            None => None,
216            Some(c) => {
217                if c.query != *query {
218                    return Err(Error::CorruptObject(
219                        "log walk cursor does not match the current revision selection".to_owned(),
220                    ));
221                }
222                Some(c.walk.clone())
223            }
224        };
225
226        let (state, commits, commit_data) = ReachableWalkState::page_retaining_commits(
227            repo,
228            resume,
229            &query.tips,
230            &query.hide,
231            limit,
232            query.cutoffs,
233        )?;
234        let next = state
235            .has_emittable_pending(query.cutoffs)
236            .then(|| LogWalkCursor {
237                query: query.clone(),
238                walk: state.snapshot(),
239            });
240        Ok(LogWalkPage {
241            commits,
242            next,
243            commit_data,
244        })
245    }
246
247    /// Encode this cursor for `--cursor` (URL-safe, opaque to callers).
248    ///
249    /// # Errors
250    ///
251    /// Returns [`Error::CorruptObject`] if internal serialization fails.
252    pub fn encode(&self) -> Result<String> {
253        let mut buf = Vec::new();
254        write_snapshot_v3(&mut buf, &self.query, &self.walk)?;
255        Ok(format!("{CURSOR_PREFIX}{}", base64_encode(&buf)))
256    }
257
258    /// Store this cursor under `.git/grit/log-walk/` and return a short `--cursor` token.
259    ///
260    /// # Errors
261    ///
262    /// Returns [`Error::CorruptObject`] if internal serialization fails, or [`Error::Io`] when
263    /// the snapshot cannot be written.
264    pub fn encode_short(&self, repo: &Repository) -> Result<String> {
265        let mut buf = Vec::new();
266        write_snapshot_v3(&mut buf, &self.query, &self.walk)?;
267        let id = random_short_cursor_id()?;
268        write_short_cursor_file(repo.git_dir(), &id, &buf)?;
269        Ok(format!("{SHORT_CURSOR_PREFIX}{id}"))
270    }
271
272    /// Decode a `--cursor` token produced by [`Self::encode`] or [`Self::encode_short`].
273    ///
274    /// Short tokens (`lw1.s.…`) require `git_dir` for the repository that issued them.
275    ///
276    /// # Errors
277    ///
278    /// Returns [`Error::CorruptObject`] when the token is malformed.
279    pub fn decode(token: &str) -> Result<Self> {
280        Self::decode_with_git_dir(token, None)
281    }
282
283    /// Decode a `--cursor` token, resolving short tokens from `git_dir` when needed.
284    ///
285    /// # Errors
286    ///
287    /// Returns [`Error::CorruptObject`] when the token is malformed.
288    pub fn decode_with_git_dir(token: &str, git_dir: Option<&Path>) -> Result<Self> {
289        if let Some(id) = token.strip_prefix(SHORT_CURSOR_PREFIX) {
290            if id.len() != SHORT_CURSOR_ID_LEN || !id.bytes().all(is_short_cursor_id_byte) {
291                return Err(Error::CorruptObject(
292                    "malformed short log walk cursor".to_owned(),
293                ));
294            }
295            let git_dir = git_dir.ok_or_else(|| {
296                Error::CorruptObject("short log walk cursor requires an open repository".to_owned())
297            })?;
298            let path = log_walk_store_dir(git_dir).join(id);
299            let bytes = std::fs::read(&path).map_err(|e| {
300                Error::CorruptObject(format!("short log walk cursor not found: {e}"))
301            })?;
302            return read_snapshot(&bytes);
303        }
304        let payload = token
305            .strip_prefix(CURSOR_PREFIX)
306            .ok_or_else(|| Error::CorruptObject("unrecognized log walk cursor".to_owned()))?;
307        let bytes = base64_decode(payload.as_bytes())
308            .map_err(|_| Error::CorruptObject("log walk cursor base64 decode failed".to_owned()))?;
309        read_snapshot(&bytes)
310    }
311}
312
313fn is_short_cursor_id_byte(b: u8) -> bool {
314    b.is_ascii_lowercase() || b.is_ascii_digit()
315}
316
317fn log_walk_store_dir(git_dir: &Path) -> PathBuf {
318    git_dir.join("grit/log-walk")
319}
320
321fn random_short_cursor_id() -> Result<String> {
322    const ALPHABET: &[u8; 36] = b"0123456789abcdefghijklmnopqrstuvwxyz";
323    let mut buf = [0u8; SHORT_CURSOR_ID_LEN];
324    getrandom::getrandom(&mut buf).map_err(|e| Error::Io(std::io::Error::other(e.to_string())))?;
325    Ok(buf
326        .iter()
327        .map(|&b| ALPHABET[(b as usize) % ALPHABET.len()] as char)
328        .collect())
329}
330
331fn write_short_cursor_file(git_dir: &Path, id: &str, snapshot: &[u8]) -> Result<()> {
332    let dir = log_walk_store_dir(git_dir);
333    std::fs::create_dir_all(&dir).map_err(Error::Io)?;
334    let path = dir.join(id);
335    let tmp = dir.join(format!("{id}.tmp"));
336    std::fs::write(&tmp, snapshot).map_err(Error::Io)?;
337    std::fs::rename(&tmp, &path).map_err(Error::Io)
338}
339
340fn write_snapshot_v3(
341    out: &mut Vec<u8>,
342    query: &LogWalkQuery,
343    walk: &ReachableWalkSnapshot,
344) -> Result<()> {
345    out.write_all(MAGIC_V3).map_err(Error::Io)?;
346    write_oid_vec(out, &query.tips)?;
347    write_oid_vec(out, &query.hide)?;
348    write_i64(out, query.cutoffs.since.unwrap_or(NO_CUTOFF))?;
349    write_i64(out, query.cutoffs.until.unwrap_or(NO_CUTOFF))?;
350    write_walk_body(out, walk)
351}
352
353fn write_walk_body(out: &mut Vec<u8>, walk: &ReachableWalkSnapshot) -> Result<()> {
354    write_u64(out, walk.seq)?;
355    write_oid_vec(out, &walk.hide)?;
356    write_oid_vec(out, &walk.done)?;
357    write_oid_vec(out, &walk.queued)?;
358    // Reserved `(oid, u32)` list, always written empty; older v3 cursors may fill it.
359    write_u32(out, 0)?;
360    write_oid_vec(out, &walk.seeded)?;
361    write_u32(out, walk.heap.len() as u32)?;
362    for &(time, seq, oid) in &walk.heap {
363        write_i64(out, time)?;
364        write_u64(out, seq)?;
365        out.write_all(oid.as_bytes()).map_err(Error::Io)?;
366    }
367    Ok(())
368}
369
370fn read_snapshot(bytes: &[u8]) -> Result<LogWalkCursor> {
371    let mut cur = Cursor::new(bytes);
372    let mut magic = [0u8; 4];
373    cur.read_exact(&mut magic)
374        .map_err(|_| Error::CorruptObject("truncated log walk cursor".to_owned()))?;
375    let query = if &magic == MAGIC_V3 {
376        let tips = read_oid_vec(&mut cur)?;
377        let hide = read_oid_vec(&mut cur)?;
378        let since_raw = read_i64(&mut cur)?;
379        let until_raw = read_i64(&mut cur)?;
380        LogWalkQuery {
381            tips,
382            hide,
383            cutoffs: ReachableWalkCommitterCutoffs {
384                since: (since_raw != NO_CUTOFF).then_some(since_raw),
385                until: (until_raw != NO_CUTOFF).then_some(until_raw),
386            },
387        }
388    } else if &magic == MAGIC_V2 {
389        let anchor = read_oid(&mut cur)?;
390        LogWalkQuery {
391            tips: vec![anchor],
392            hide: Vec::new(),
393            cutoffs: ReachableWalkCommitterCutoffs::default(),
394        }
395    } else {
396        return Err(Error::CorruptObject(
397            "log walk cursor version mismatch".to_owned(),
398        ));
399    };
400    let walk = read_walk_body(&mut cur)?;
401    if cur.position() != bytes.len() as u64 {
402        return Err(Error::CorruptObject(
403            "log walk cursor trailing bytes".to_owned(),
404        ));
405    }
406    Ok(LogWalkCursor { query, walk })
407}
408
409fn read_walk_body(cur: &mut Cursor<&[u8]>) -> Result<ReachableWalkSnapshot> {
410    let seq = read_u64(cur)?;
411    let hide = read_oid_vec(cur)?;
412    let done = read_oid_vec(cur)?;
413    let queued = read_oid_vec(cur)?;
414    for _ in 0..read_u32(cur)? {
415        read_oid(cur)?;
416        read_u32(cur)?;
417    }
418    let seeded = read_oid_vec(cur)?;
419    let heap_len = read_u32(cur)? as usize;
420    let mut heap = Vec::with_capacity(heap_len);
421    for _ in 0..heap_len {
422        let time = read_i64(cur)?;
423        let seq_entry = read_u64(cur)?;
424        let oid = read_oid(cur)?;
425        heap.push((time, seq_entry, oid));
426    }
427    Ok(ReachableWalkSnapshot {
428        heap,
429        done,
430        queued,
431        seeded,
432        seq,
433        hide,
434    })
435}
436
437fn write_u32(out: &mut Vec<u8>, v: u32) -> Result<()> {
438    out.write_all(&v.to_le_bytes()).map_err(Error::Io)
439}
440
441fn write_u64(out: &mut Vec<u8>, v: u64) -> Result<()> {
442    out.write_all(&v.to_le_bytes()).map_err(Error::Io)
443}
444
445fn write_i64(out: &mut Vec<u8>, v: i64) -> Result<()> {
446    out.write_all(&v.to_le_bytes()).map_err(Error::Io)
447}
448
449fn read_u32(cur: &mut Cursor<&[u8]>) -> Result<u32> {
450    let mut buf = [0u8; 4];
451    cur.read_exact(&mut buf)
452        .map_err(|_| Error::CorruptObject("truncated log walk cursor".to_owned()))?;
453    Ok(u32::from_le_bytes(buf))
454}
455
456fn read_u64(cur: &mut Cursor<&[u8]>) -> Result<u64> {
457    let mut buf = [0u8; 8];
458    cur.read_exact(&mut buf)
459        .map_err(|_| Error::CorruptObject("truncated log walk cursor".to_owned()))?;
460    Ok(u64::from_le_bytes(buf))
461}
462
463fn read_i64(cur: &mut Cursor<&[u8]>) -> Result<i64> {
464    let mut buf = [0u8; 8];
465    cur.read_exact(&mut buf)
466        .map_err(|_| Error::CorruptObject("truncated log walk cursor".to_owned()))?;
467    Ok(i64::from_le_bytes(buf))
468}
469
470fn write_oid_vec(out: &mut Vec<u8>, oids: &[ObjectId]) -> Result<()> {
471    write_u32(out, oids.len() as u32)?;
472    for oid in oids {
473        out.write_all(oid.as_bytes()).map_err(Error::Io)?;
474    }
475    Ok(())
476}
477
478fn read_oid_vec(cur: &mut Cursor<&[u8]>) -> Result<Vec<ObjectId>> {
479    let len = read_u32(cur)? as usize;
480    let mut out = Vec::with_capacity(len);
481    for _ in 0..len {
482        out.push(read_oid(cur)?);
483    }
484    Ok(out)
485}
486
487fn read_oid(cur: &mut Cursor<&[u8]>) -> Result<ObjectId> {
488    let mut buf = [0u8; 20];
489    cur.read_exact(&mut buf)
490        .map_err(|_| Error::CorruptObject("truncated log walk cursor".to_owned()))?;
491    ObjectId::from_bytes(&buf)
492}
493
494fn base64_encode(input: &[u8]) -> String {
495    const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
496    let mut out = String::new();
497    let mut i = 0;
498    while i + 3 <= input.len() {
499        let n = ((input[i] as u32) << 16) | ((input[i + 1] as u32) << 8) | input[i + 2] as u32;
500        out.push(TABLE[((n >> 18) & 63) as usize] as char);
501        out.push(TABLE[((n >> 12) & 63) as usize] as char);
502        out.push(TABLE[((n >> 6) & 63) as usize] as char);
503        out.push(TABLE[(n & 63) as usize] as char);
504        i += 3;
505    }
506    let rem = input.len() - i;
507    if rem == 1 {
508        let n = (input[i] as u32) << 16;
509        out.push(TABLE[((n >> 18) & 63) as usize] as char);
510        out.push(TABLE[((n >> 12) & 63) as usize] as char);
511        out.push('=');
512        out.push('=');
513    } else if rem == 2 {
514        let n = ((input[i] as u32) << 16) | ((input[i + 1] as u32) << 8);
515        out.push(TABLE[((n >> 18) & 63) as usize] as char);
516        out.push(TABLE[((n >> 12) & 63) as usize] as char);
517        out.push(TABLE[((n >> 6) & 63) as usize] as char);
518        out.push('=');
519    }
520    out
521}
522
523fn base64_decode(input: &[u8]) -> std::result::Result<Vec<u8>, ()> {
524    fn val(b: u8) -> std::result::Result<u8, ()> {
525        match b {
526            b'A'..=b'Z' => Ok(b - b'A'),
527            b'a'..=b'z' => Ok(b - b'a' + 26),
528            b'0'..=b'9' => Ok(b - b'0' + 52),
529            b'+' => Ok(62),
530            b'/' => Ok(63),
531            b'=' => Ok(0),
532            _ => Err(()),
533        }
534    }
535    let mut out = Vec::new();
536    let mut buf = [0u8; 4];
537    let mut n = 0usize;
538    for &b in input {
539        if b.is_ascii_whitespace() {
540            continue;
541        }
542        buf[n] = b;
543        n += 1;
544        if n == 4 {
545            if buf[0] == b'=' {
546                return Err(());
547            }
548            let a = val(buf[0])?;
549            let b = val(buf[1])?;
550            let c = val(buf[2])?;
551            let d = val(buf[3])?;
552            let triple = ((a as u32) << 18) | ((b as u32) << 12) | ((c as u32) << 6) | d as u32;
553            out.push((triple >> 16) as u8);
554            if buf[2] != b'=' {
555                out.push((triple >> 8) as u8);
556            }
557            if buf[3] != b'=' {
558                out.push(triple as u8);
559            }
560            n = 0;
561        }
562    }
563    if n != 0 {
564        return Err(());
565    }
566    Ok(out)
567}
568
569#[cfg(test)]
570mod tests {
571    use super::*;
572    use crate::environment::RepositoryOptions;
573    use crate::objects::ObjectKind;
574    use crate::odb::Odb;
575    use crate::repo::{InitOptions, Repository};
576    use std::collections::HashSet;
577    use std::process::Command;
578    use tempfile::tempdir;
579
580    fn empty_tree(odb: &Odb) -> Result<ObjectId> {
581        odb.write_loose_materialize(ObjectKind::Tree, b"")
582    }
583
584    fn write_commit(odb: &Odb, parents: &[ObjectId], msg: &str, time: i64) -> Result<ObjectId> {
585        let tree = empty_tree(odb)?;
586        let mut body = format!("tree {tree}\n");
587        for p in parents {
588            body.push_str(&format!("parent {p}\n"));
589        }
590        body.push_str(&format!(
591            "author T <t@e.com> {time} +0000\ncommitter T <t@e.com> {time} +0000\n\n{msg}\n"
592        ));
593        odb.write_loose_materialize(ObjectKind::Commit, body.as_bytes())
594    }
595
596    fn open_repo(dir: &tempfile::TempDir) -> Result<Repository> {
597        Repository::init(
598            dir.path(),
599            &InitOptions {
600                bare: true,
601
602                ref_storage: crate::RefStorageFormat::Files,
603                ..InitOptions::default()
604            },
605        )?;
606        Repository::open(dir.path(), &RepositoryOptions::empty())
607    }
608
609    fn git_log_oids(repo: &Repository, max: usize) -> Vec<ObjectId> {
610        let out = Command::new("git")
611            .args(["-C", repo.git_dir().to_str().expect("utf8"), "log", "-n"])
612            .arg(max.to_string())
613            .args(["--format=%H"])
614            .output()
615            .expect("git log");
616        assert!(out.status.success());
617        String::from_utf8_lossy(&out.stdout)
618            .lines()
619            .filter_map(|line| line.parse().ok())
620            .collect()
621    }
622
623    #[cfg_attr(
624        not(feature = "full-tests"),
625        ignore = "starts system git or is heavy; run with --features full-tests"
626    )]
627    #[test]
628    fn cursor_roundtrip_and_resume_matches_git_log() -> Result<()> {
629        let dir = tempdir().map_err(Error::Io)?;
630        let repo = open_repo(&dir)?;
631        let base = write_commit(&repo.odb(), &[], "base", 100)?;
632        let side = write_commit(&repo.odb(), &[base], "side", 150)?;
633        let main1 = write_commit(&repo.odb(), &[base], "main1", 200)?;
634        let merge = write_commit(&repo.odb(), &[main1, side], "merge", 300)?;
635        let tip = write_commit(&repo.odb(), &[merge], "tip", 400)?;
636        crate::refs::write_ref(repo.git_dir(), "refs/heads/main", &tip)?;
637        crate::refs::write_symbolic_ref(repo.git_dir(), "HEAD", "refs/heads/main")?;
638
639        const PAGE: usize = 3;
640        let query = LogWalkQuery {
641            tips: vec![tip],
642            hide: Vec::new(),
643            cutoffs: ReachableWalkCommitterCutoffs::default(),
644        };
645        let mut cursor: Option<LogWalkCursor> = None;
646        let mut paged = Vec::new();
647        loop {
648            let page = LogWalkCursor::step(&repo, &query, cursor.as_ref(), PAGE)?;
649            paged.extend(page.commits.clone());
650            match page.next {
651                Some(next) => {
652                    let encoded = next.encode()?;
653                    cursor = Some(LogWalkCursor::decode(&encoded)?);
654                }
655                None => break,
656            }
657        }
658
659        let git_order = git_log_oids(&repo, 20);
660        assert_eq!(paged, git_order);
661        Ok(())
662    }
663
664    #[test]
665    fn short_cursor_roundtrip_via_repo_store() -> Result<()> {
666        let dir = tempdir().map_err(Error::Io)?;
667        let repo = open_repo(&dir)?;
668        let base = write_commit(&repo.odb(), &[], "base", 100)?;
669        let tip = write_commit(&repo.odb(), &[base], "tip", 200)?;
670        let query = LogWalkQuery {
671            tips: vec![tip],
672            hide: Vec::new(),
673            cutoffs: ReachableWalkCommitterCutoffs::default(),
674        };
675        let page = LogWalkCursor::step(&repo, &query, None, 1)?;
676        let next = page.next.expect("continuation");
677        let short = next.encode_short(&repo)?;
678        assert!(short.starts_with(SHORT_CURSOR_PREFIX));
679        assert_eq!(short.len(), SHORT_CURSOR_PREFIX.len() + SHORT_CURSOR_ID_LEN);
680        let decoded = LogWalkCursor::decode_with_git_dir(&short, Some(repo.git_dir()))?;
681        assert_eq!(decoded, next);
682        Ok(())
683    }
684
685    #[test]
686    fn page_commit_data_holds_the_commits_the_walk_read() -> Result<()> {
687        let dir = tempdir().map_err(Error::Io)?;
688        let repo = open_repo(&dir)?;
689        let base = write_commit(&repo.odb(), &[], "base", 100)?;
690        let side = write_commit(&repo.odb(), &[base], "side", 150)?;
691        let main1 = write_commit(&repo.odb(), &[base], "main1", 200)?;
692        let merge = write_commit(&repo.odb(), &[main1, side], "merge", 300)?;
693        let tip = write_commit(&repo.odb(), &[merge], "tip", 400)?;
694        let query = LogWalkQuery {
695            tips: vec![tip],
696            hide: Vec::new(),
697            cutoffs: ReachableWalkCommitterCutoffs::default(),
698        };
699        let page = LogWalkCursor::step(&repo, &query, None, 10)?;
700        assert_eq!(page.commits.len(), 5);
701        // Seeding the walk reads the tip linkage first, so only the tip may be missing.
702        assert!(page.commit_data.len() >= page.commits.len() - 1);
703        let retained: Vec<ObjectId> = page.commit_data.oids().copied().collect();
704        let mut commit_data = page.commit_data.clone();
705        for oid in retained {
706            assert!(page.commits.contains(&oid));
707            assert_eq!(
708                commit_data.take_commit(&oid).transpose()?,
709                Some(parse_commit(&repo.odb().read(&oid)?.data)?)
710            );
711        }
712
713        let reopened = Repository::open(dir.path(), &RepositoryOptions::empty())?;
714        let limited = LogWalkCursor::step(&reopened, &query, None, 2)?;
715        assert!(limited
716            .commit_data
717            .oids()
718            .all(|oid| limited.commits.contains(oid)));
719        Ok(())
720    }
721
722    #[test]
723    fn page_commit_data_skips_commits_behind_a_replace_ref() -> Result<()> {
724        let dir = tempdir().map_err(Error::Io)?;
725        let repo = open_repo(&dir)?;
726        let base = write_commit(&repo.odb(), &[], "base", 100)?;
727        let mid = write_commit(&repo.odb(), &[base], "mid", 200)?;
728        let tip = write_commit(&repo.odb(), &[mid], "tip", 300)?;
729        let replacement = write_commit(&repo.odb(), &[base], "replacement", 200)?;
730        crate::refs::write_ref(
731            repo.git_dir(),
732            &format!("refs/replace/{}", mid.to_hex()),
733            &replacement,
734        )?;
735        let repo = Repository::open(dir.path(), &RepositoryOptions::empty())?;
736        let query = LogWalkQuery {
737            tips: vec![tip],
738            hide: Vec::new(),
739            cutoffs: ReachableWalkCommitterCutoffs::default(),
740        };
741        let page = LogWalkCursor::step(&repo, &query, None, 10)?;
742        assert!(page.commits.contains(&mid));
743        assert!(!page.commit_data.contains(&mid));
744        assert!(page.commit_data.contains(&base));
745        Ok(())
746    }
747
748    #[cfg_attr(
749        not(feature = "full-tests"),
750        ignore = "starts system git or is heavy; run with --features full-tests"
751    )]
752    #[test]
753    fn equal_committer_dates_match_git_log_order() -> Result<()> {
754        let dir = tempdir().map_err(Error::Io)?;
755        let repo = open_repo(&dir)?;
756        let same = 1_735_689_600i64; // 2026-01-01T00:00:00Z
757        let base = write_commit(&repo.odb(), &[], "base", same)?;
758        let side = write_commit(&repo.odb(), &[base], "side", same)?;
759        let mainedit = write_commit(&repo.odb(), &[base], "mainedit", same)?;
760        let merge = write_commit(&repo.odb(), &[mainedit, side], "merge", same)?;
761        crate::refs::write_ref(repo.git_dir(), "refs/heads/main", &merge)?;
762        crate::refs::write_symbolic_ref(repo.git_dir(), "HEAD", "refs/heads/main")?;
763
764        let git_order = git_log_oids(&repo, 10);
765        let query = LogWalkQuery {
766            tips: vec![merge],
767            hide: Vec::new(),
768            cutoffs: ReachableWalkCommitterCutoffs::default(),
769        };
770        let page = LogWalkCursor::step(&repo, &query, None, 10)?;
771        assert_eq!(page.commits, git_order);
772        assert_no_ancestor_before_descendant(&repo, &page.commits);
773        Ok(())
774    }
775
776    fn assert_no_ancestor_before_descendant(repo: &Repository, order: &[ObjectId]) {
777        for (i, &ancestor) in order.iter().enumerate() {
778            for &maybe_descendant in &order[i + 1..] {
779                assert!(
780                    !crate::merge_base::is_ancestor(repo, ancestor, maybe_descendant)
781                        .expect("ancestry"),
782                    "ancestor {ancestor} listed before descendant {maybe_descendant}"
783                );
784            }
785        }
786    }
787
788    #[test]
789    fn since_cutoff_does_not_emit_stale_continuation_cursor() -> Result<()> {
790        let dir = tempdir().map_err(Error::Io)?;
791        let repo = open_repo(&dir)?;
792        let c1 = write_commit(&repo.odb(), &[], "c1", 100)?;
793        let c2 = write_commit(&repo.odb(), &[c1], "c2", 200)?;
794        let c3 = write_commit(&repo.odb(), &[c2], "c3", 300)?;
795        let tip = write_commit(&repo.odb(), &[c3], "tip", 400)?;
796        crate::refs::write_ref(repo.git_dir(), "refs/heads/main", &tip)?;
797        crate::refs::write_symbolic_ref(repo.git_dir(), "HEAD", "refs/heads/main")?;
798
799        let query = LogWalkQuery {
800            tips: vec![tip],
801            hide: Vec::new(),
802            cutoffs: ReachableWalkCommitterCutoffs {
803                since: Some(250),
804                until: None,
805            },
806        };
807        let page = LogWalkCursor::step(&repo, &query, None, 10)?;
808        assert_eq!(page.commits, vec![tip, c3]);
809        assert!(
810            page.next.is_none(),
811            "since cutoff must not leave a continuation cursor with pruned heap state"
812        );
813        Ok(())
814    }
815
816    #[test]
817    fn since_cutoff_at_limit_does_not_false_continue() -> Result<()> {
818        let dir = tempdir().map_err(Error::Io)?;
819        let repo = open_repo(&dir)?;
820        let c1 = write_commit(&repo.odb(), &[], "c1", 100)?;
821        let c2 = write_commit(&repo.odb(), &[c1], "c2", 200)?;
822        let c3 = write_commit(&repo.odb(), &[c2], "c3", 300)?;
823        let tip = write_commit(&repo.odb(), &[c3], "tip", 400)?;
824        crate::refs::write_ref(repo.git_dir(), "refs/heads/main", &tip)?;
825        crate::refs::write_symbolic_ref(repo.git_dir(), "HEAD", "refs/heads/main")?;
826
827        let query = LogWalkQuery {
828            tips: vec![tip],
829            hide: Vec::new(),
830            cutoffs: ReachableWalkCommitterCutoffs {
831                since: Some(250),
832                until: None,
833            },
834        };
835        let page = LogWalkCursor::step(&repo, &query, None, 2)?;
836        assert_eq!(page.commits, vec![tip, c3]);
837        assert!(
838            page.next.is_none(),
839            "limit matching since-filtered count must not imply more pages"
840        );
841        Ok(())
842    }
843
844    #[test]
845    fn decodes_v3_cursor_with_filled_reserved_list() -> Result<()> {
846        let tip: ObjectId = "1111111111111111111111111111111111111111"
847            .parse()
848            .map_err(|_| Error::CorruptObject("oid".to_owned()))?;
849        let parent: ObjectId = "2222222222222222222222222222222222222222"
850            .parse()
851            .map_err(|_| Error::CorruptObject("oid".to_owned()))?;
852        let mut buf = Vec::new();
853        buf.extend_from_slice(MAGIC_V3);
854        write_oid_vec(&mut buf, &[tip])?;
855        write_oid_vec(&mut buf, &[])?;
856        write_i64(&mut buf, NO_CUTOFF)?;
857        write_i64(&mut buf, NO_CUTOFF)?;
858        write_u64(&mut buf, 2)?;
859        write_oid_vec(&mut buf, &[])?;
860        write_oid_vec(&mut buf, &[tip])?;
861        write_oid_vec(&mut buf, &[tip, parent])?;
862        write_u32(&mut buf, 1)?;
863        buf.extend_from_slice(parent.as_bytes());
864        write_u32(&mut buf, 0)?;
865        write_oid_vec(&mut buf, &[tip])?;
866        write_u32(&mut buf, 1)?;
867        write_i64(&mut buf, 100)?;
868        write_u64(&mut buf, 1)?;
869        buf.extend_from_slice(parent.as_bytes());
870
871        let token = format!("{CURSOR_PREFIX}{}", base64_encode(&buf));
872        let cursor = LogWalkCursor::decode(&token)?;
873        assert_eq!(cursor.query().tips, vec![tip]);
874        assert_eq!(cursor.walk.heap, vec![(100, 1, parent)]);
875        assert_eq!(cursor.walk.queued, vec![tip, parent]);
876
877        let reencoded = LogWalkCursor::decode(&cursor.encode()?)?;
878        assert_eq!(reencoded, cursor);
879        Ok(())
880    }
881
882    #[cfg_attr(
883        not(feature = "full-tests"),
884        ignore = "starts system git or is heavy; run with --features full-tests"
885    )]
886    #[test]
887    fn range_pages_match_git_log_under_clock_skew() -> Result<()> {
888        let dir = tempdir().map_err(Error::Io)?;
889        let repo = open_repo(&dir)?;
890        let base = write_commit(&repo.odb(), &[], "base", 50)?;
891        let shared = write_commit(&repo.odb(), &[base], "shared", 120)?;
892        let newer = write_commit(&repo.odb(), &[shared], "newer", 350)?;
893        // Older than its parent: Git prints `shared` before this child.
894        let skewed = write_commit(&repo.odb(), &[shared], "skewed", 100)?;
895        let merge = write_commit(&repo.odb(), &[newer, skewed], "merge", 400)?;
896        let tip = write_commit(&repo.odb(), &[merge], "tip", 500)?;
897        crate::refs::write_ref(repo.git_dir(), "refs/heads/main", &tip)?;
898        crate::refs::write_symbolic_ref(repo.git_dir(), "HEAD", "refs/heads/main")?;
899
900        let out = Command::new("git")
901            .args(["-C", repo.git_dir().to_str().expect("utf8"), "rev-list"])
902            .arg(tip.to_hex())
903            .arg(format!("^{base}"))
904            .output()
905            .expect("git rev-list");
906        assert!(out.status.success());
907        let git_order: Vec<ObjectId> = String::from_utf8_lossy(&out.stdout)
908            .lines()
909            .filter_map(|line| line.parse().ok())
910            .collect();
911        assert_eq!(git_order, vec![tip, merge, newer, shared, skewed]);
912
913        let query = LogWalkQuery {
914            tips: vec![tip],
915            hide: vec![base],
916            cutoffs: ReachableWalkCommitterCutoffs::default(),
917        };
918        let mut cursor: Option<LogWalkCursor> = None;
919        let mut paged = Vec::new();
920        loop {
921            let page = LogWalkCursor::step(&repo, &query, cursor.as_ref(), 2)?;
922            paged.extend(page.commits);
923            match page.next {
924                Some(next) => cursor = Some(LogWalkCursor::decode(&next.encode()?)?),
925                None => break,
926            }
927        }
928        assert_eq!(paged, git_order);
929        Ok(())
930    }
931
932    /// Run system `git` in `dir`, isolated from the user's and system config.
933    fn git_in(dir: &std::path::Path, args: &[&str]) -> String {
934        let out = Command::new("git")
935            .current_dir(dir)
936            .args(args)
937            .env("GIT_CONFIG_GLOBAL", "/dev/null")
938            .env("GIT_CONFIG_NOSYSTEM", "1")
939            .output()
940            .expect("git");
941        assert!(
942            out.status.success(),
943            "git {args:?}: {}",
944            String::from_utf8_lossy(&out.stderr)
945        );
946        String::from_utf8_lossy(&out.stdout).into_owned()
947    }
948
949    #[cfg_attr(
950        not(feature = "full-tests"),
951        ignore = "starts system git or is heavy; run with --features full-tests"
952    )]
953    #[test]
954    fn walk_reads_loose_and_alternate_commits_beside_the_single_local_pack() -> Result<()> {
955        let dir = tempdir().map_err(Error::Io)?;
956        let source = dir.path().join("source");
957        let clone = dir.path().join("clone");
958        std::fs::create_dir(&source).map_err(Error::Io)?;
959        git_in(&source, &["init", "-q", "-b", "main"]);
960        let ident = [
961            "-c",
962            "user.name=T",
963            "-c",
964            "user.email=t@e.com",
965            "commit",
966            "-q",
967            "--allow-empty",
968            "-m",
969        ];
970        for msg in ["one", "two", "three"] {
971            git_in(&source, &[&ident[..], &[msg]].concat());
972        }
973        git_in(&source, &["repack", "-a", "-d", "-q"]);
974        git_in(dir.path(), &["clone", "-q", "--shared", "source", "clone"]);
975        for msg in ["four", "five"] {
976            git_in(&clone, &[&ident[..], &[msg]].concat());
977        }
978        // One local pack with the clone's own commits; the first three live only in the
979        // alternate and the newest is loose, so the single-pack reader cannot answer for them.
980        git_in(&clone, &["repack", "-a", "-d", "-l", "-q"]);
981        git_in(&clone, &["prune-packed"]);
982        git_in(&clone, &[&ident[..], &["six"]].concat());
983        let git_order: Vec<ObjectId> = git_in(&clone, &["rev-list", "HEAD"])
984            .lines()
985            .filter_map(|line| line.parse().ok())
986            .collect();
987        assert_eq!(git_order.len(), 6);
988
989        let repo = Repository::discover(Some(&clone), &RepositoryOptions::empty())?;
990        let query = LogWalkQuery {
991            tips: vec![git_order[0]],
992            hide: Vec::new(),
993            cutoffs: ReachableWalkCommitterCutoffs::default(),
994        };
995        let page = LogWalkCursor::step(&repo, &query, None, 10)?;
996        let mut walked = page.commits.clone();
997        let mut expected = git_order.clone();
998        walked.sort();
999        expected.sort();
1000        assert_eq!(walked, expected, "the walk lost loose or alternate commits");
1001        assert_commit_data_matches_odb(&repo, &page)
1002    }
1003
1004    fn assert_commit_data_matches_odb(repo: &Repository, page: &LogWalkPage) -> Result<()> {
1005        let emitted: HashSet<ObjectId> = page.commits.iter().copied().collect();
1006        let mut walked = page.commit_data.clone();
1007        let held: Vec<ObjectId> = walked.oids().copied().collect();
1008        assert_eq!(held.len(), walked.len());
1009        for oid in held {
1010            assert!(emitted.contains(&oid), "{oid} retained but not emitted");
1011            assert!(walked.contains(&oid));
1012            let data = walked.take_commit(&oid).expect("held commit")?;
1013            let fresh = crate::objects::parse_commit(&repo.odb().read(&oid)?.data)?;
1014            assert_eq!(
1015                data, fresh,
1016                "retained data for {oid} differs from the object"
1017            );
1018            assert!(!walked.contains(&oid));
1019            assert!(walked.take_commit(&oid).is_none(), "taken twice");
1020        }
1021        assert!(walked.is_empty());
1022        Ok(())
1023    }
1024
1025    #[cfg_attr(
1026        not(feature = "full-tests"),
1027        ignore = "starts system git or is heavy; run with --features full-tests"
1028    )]
1029    #[test]
1030    fn pages_carry_parsed_commits_for_every_emitted_commit_read() -> Result<()> {
1031        let dir = tempdir().map_err(Error::Io)?;
1032        let repo = open_repo(&dir)?;
1033        let base = write_commit(&repo.odb(), &[], "base", 100)?;
1034        let hidden_side = write_commit(&repo.odb(), &[base], "hidden side", 150)?;
1035        let side = write_commit(&repo.odb(), &[base], "side\n\nbody line\n", 160)?;
1036        let main = write_commit(&repo.odb(), &[base], "main", 200)?;
1037        let merge = write_commit(&repo.odb(), &[main, side], "merge", 300)?;
1038        let tip = write_commit(&repo.odb(), &[merge], "tip", 400)?;
1039        crate::refs::write_ref(repo.git_dir(), "refs/heads/main", &tip)?;
1040        crate::refs::write_symbolic_ref(repo.git_dir(), "HEAD", "refs/heads/main")?;
1041
1042        let query = LogWalkQuery {
1043            tips: vec![tip],
1044            hide: Vec::new(),
1045            cutoffs: ReachableWalkCommitterCutoffs::default(),
1046        };
1047        let first = LogWalkCursor::step(&repo, &query, None, 3)?;
1048        assert_eq!(first.commits.len(), 3);
1049        assert_eq!(first.commit_data.len(), 3, "loose commits are all read");
1050        assert_commit_data_matches_odb(&repo, &first)?;
1051        let second = LogWalkCursor::step(&repo, &query, first.next.as_ref(), 10)?;
1052        assert_commit_data_matches_odb(&repo, &second)?;
1053        let mut all = first.commits.clone();
1054        all.extend(second.commits.iter().copied());
1055        assert_eq!(all, git_log_oids(&repo, 20));
1056
1057        // Commits read only to paint the hidden side are not handed back.
1058        let reopened = Repository::open(dir.path(), &RepositoryOptions::empty())?;
1059        let range = LogWalkQuery {
1060            tips: vec![tip],
1061            hide: vec![hidden_side],
1062            cutoffs: ReachableWalkCommitterCutoffs::default(),
1063        };
1064        let page = LogWalkCursor::step(&reopened, &range, None, 10)?;
1065        assert_eq!(page.commits, vec![tip, merge, main, side]);
1066        assert!(!page.commit_data.contains(&hidden_side));
1067        assert!(!page.commit_data.contains(&base));
1068        assert_commit_data_matches_odb(&reopened, &page)?;
1069        Ok(())
1070    }
1071
1072    #[cfg_attr(
1073        not(feature = "full-tests"),
1074        ignore = "starts system git or is heavy; run with --features full-tests"
1075    )]
1076    #[test]
1077    fn commit_graph_walks_match_git_log_without_object_reads() -> Result<()> {
1078        let dir = tempdir().map_err(Error::Io)?;
1079        let repo = open_repo(&dir)?;
1080        let mut parent = write_commit(&repo.odb(), &[], "c0", 100)?;
1081        for i in 1..8 {
1082            parent = write_commit(&repo.odb(), &[parent], &format!("c{i}"), 100 + i * 10)?;
1083        }
1084        crate::refs::write_ref(repo.git_dir(), "refs/heads/main", &parent)?;
1085        crate::refs::write_symbolic_ref(repo.git_dir(), "HEAD", "refs/heads/main")?;
1086        let status = Command::new("git")
1087            .args(["-C", repo.git_dir().to_str().expect("utf8")])
1088            .args(["commit-graph", "write", "--reachable"])
1089            .status()
1090            .expect("git commit-graph write");
1091        assert!(status.success());
1092
1093        let repo = Repository::open(dir.path(), &RepositoryOptions::empty())?;
1094        let query = LogWalkQuery {
1095            tips: vec![parent],
1096            hide: Vec::new(),
1097            cutoffs: ReachableWalkCommitterCutoffs::default(),
1098        };
1099        let page = LogWalkCursor::step(&repo, &query, None, 20)?;
1100        assert_eq!(page.commits, git_log_oids(&repo, 20));
1101        assert_commit_data_matches_odb(&repo, &page)?;
1102        Ok(())
1103    }
1104
1105    #[test]
1106    fn paging_includes_second_parent_after_merge_boundary() -> Result<()> {
1107        let dir = tempdir().map_err(Error::Io)?;
1108        let repo = open_repo(&dir)?;
1109        let base = write_commit(&repo.odb(), &[], "base", 100)?;
1110        let side = write_commit(&repo.odb(), &[base], "side-only", 120)?;
1111        let main = write_commit(&repo.odb(), &[base], "main", 200)?;
1112        let merge = write_commit(&repo.odb(), &[main, side], "merge", 250)?;
1113        crate::refs::write_ref(repo.git_dir(), "refs/heads/main", &merge)?;
1114        crate::refs::write_symbolic_ref(repo.git_dir(), "HEAD", "refs/heads/main")?;
1115
1116        let query = LogWalkQuery {
1117            tips: vec![merge],
1118            hide: Vec::new(),
1119            cutoffs: ReachableWalkCommitterCutoffs::default(),
1120        };
1121        let page1 = LogWalkCursor::step(&repo, &query, None, 1)?;
1122        assert_eq!(page1.commits, vec![merge]);
1123        assert!(page1.next.is_some());
1124
1125        let page2 = LogWalkCursor::step(&repo, &query, page1.next.as_ref(), 10)?;
1126        let seen: HashSet<_> = page1
1127            .commits
1128            .iter()
1129            .chain(page2.commits.iter())
1130            .copied()
1131            .collect();
1132        assert!(
1133            seen.contains(&side),
1134            "second parent must appear after merge page"
1135        );
1136        Ok(())
1137    }
1138}