1use 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.";
18const 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#[derive(Clone, Debug, PartialEq, Eq)]
27pub struct LogWalkQuery {
28 pub tips: Vec<ObjectId>,
30 pub hide: Vec<ObjectId>,
32 pub cutoffs: ReachableWalkCommitterCutoffs,
34}
35
36impl LogWalkQuery {
37 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#[derive(Clone, Debug, PartialEq, Eq)]
75pub struct LogWalkCursor {
76 query: LogWalkQuery,
77 walk: ReachableWalkSnapshot,
78}
79
80#[derive(Clone, Debug, PartialEq, Eq)]
82pub struct LogWalkPage {
83 pub commits: Vec<ObjectId>,
85 pub next: Option<LogWalkCursor>,
87 pub commit_data: WalkedCommits,
94}
95
96#[derive(Clone, Debug, Default)]
102pub struct WalkedCommits {
103 bytes: Vec<u8>,
104 spans: OidHashMap<Range<usize>>,
105}
106
107impl WalkedCommits {
108 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 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 pub fn retain(&mut self, keep: impl Fn(&ObjectId) -> bool) {
125 self.spans.retain(|oid, _| keep(oid));
126 }
127
128 #[must_use]
130 pub fn len(&self) -> usize {
131 self.spans.len()
132 }
133
134 #[must_use]
136 pub fn is_empty(&self) -> bool {
137 self.spans.is_empty()
138 }
139
140 #[must_use]
142 pub fn contains(&self, oid: &ObjectId) -> bool {
143 self.spans.contains_key(oid)
144 }
145
146 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
156impl 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 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 #[must_use]
187 pub fn query(&self) -> &LogWalkQuery {
188 &self.query
189 }
190
191 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 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 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 pub fn decode(token: &str) -> Result<Self> {
280 Self::decode_with_git_dir(token, None)
281 }
282
283 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 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 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; 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 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 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 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 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}