1pub mod bisect;
2pub mod graph;
3pub mod revlist;
4mod setup;
5
6use sley_config::GitConfig;
7use sley_core::{GitError, MissingObjectContext, ObjectFormat, ObjectId, Result};
8
9pub use setup::{
10 MatchedRef, NoWalkMode, PseudoRefResolver, RevisionOptions, RevisionOrder,
11 RevisionSetupContext, RevisionSymmetricRange, RevisionTip, SetupRevisions,
12 ambiguous_argument_error, ambiguous_argument_message, setup_revisions, setup_revisions_os,
13};
14pub use sley_core::BString;
15use sley_formats::CommitGraph;
16use sley_index::Index;
17use sley_object::{Commit, EncodedObject, ObjectType, Tag, TreeEntries};
18use sley_odb::{FileObjectDatabase, ObjectPrefixResolution, ObjectReader, repository_objects_dir};
19use sley_refs::{
20 FileRefStore, PackedRef, RefTarget, ReflogEntry, validate_ref_name_for_read,
21 validate_symref_target,
22};
23use std::collections::{HashMap, HashSet, VecDeque};
24use std::fs;
25use std::ops::Range;
26use std::path::{Path, PathBuf};
27use std::sync::{Arc, Mutex, OnceLock};
28
29fn read_revision_object<R: ObjectReader>(reader: &R, oid: &ObjectId) -> Result<Arc<EncodedObject>> {
30 reader
31 .read_object(oid)
32 .map_err(|err| with_missing_object_context(err, *oid, MissingObjectContext::RevisionWalk))
33}
34
35fn with_missing_object_context(
36 err: GitError,
37 oid: ObjectId,
38 context: MissingObjectContext,
39) -> GitError {
40 let kind = err
41 .not_found_kind()
42 .and_then(sley_core::NotFoundKind::missing_object_kind);
43 match kind {
44 Some(kind) => GitError::object_kind_not_found_in(oid, kind, context),
45 None => err,
46 }
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct RevisionSpec {
51 pub raw: String,
52}
53
54impl RevisionSpec {
55 pub fn parse(raw: impl Into<String>) -> Result<Self> {
56 let raw = raw.into();
57 if raw.is_empty() {
58 return Err(GitError::InvalidFormat("empty revision spec".into()));
59 }
60 Ok(Self { raw })
61 }
62
63 pub fn borrowed(&self) -> Result<RevisionSpecRef<'_>> {
64 RevisionSpecRef::parse(&self.raw)
65 }
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub struct RevisionSpecRef<'a> {
79 raw: &'a str,
80 kind: RevisionSpecKind<'a>,
81}
82
83impl<'a> RevisionSpecRef<'a> {
84 pub fn parse(raw: &'a str) -> Result<Self> {
85 if raw.is_empty() {
86 return Err(GitError::InvalidFormat("empty revision spec".into()));
87 }
88 let kind = if let Some(text) = raw.strip_prefix(":/") {
89 RevisionSpecKind::MessageSearch { text }
90 } else if let Some(rest) = raw.strip_prefix(':') {
91 let (stage, path) = parse_index_stage_path(rest);
92 RevisionSpecKind::IndexPath { stage, path }
93 } else if let Some((rev, path)) = split_top_level_rev_path(raw) {
94 RevisionSpecKind::TreePath { rev, path }
95 } else {
96 RevisionSpecKind::Revision { rev: raw }
97 };
98 Ok(Self { raw, kind })
99 }
100
101 pub fn raw(&self) -> &'a str {
102 self.raw
103 }
104
105 pub fn kind(&self) -> RevisionSpecKind<'a> {
106 self.kind
107 }
108
109 pub fn tree_path(&self) -> Option<(&'a str, &'a str)> {
110 match self.kind {
111 RevisionSpecKind::TreePath { rev, path } => Some((rev, path)),
112 _ => None,
113 }
114 }
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub enum RevisionSpecKind<'a> {
119 MessageSearch { text: &'a str },
120 IndexPath { stage: u8, path: &'a str },
121 TreePath { rev: &'a str, path: &'a str },
122 Revision { rev: &'a str },
123}
124
125#[derive(Debug, Clone, PartialEq, Eq)]
126pub struct CommitRecord {
127 pub oid: ObjectId,
128 pub parents: Vec<ObjectId>,
129 pub commit: Commit,
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub enum ObjectDisambiguation {
134 Any,
135 Commit,
136 Commitish,
137 Tree,
138 Treeish,
139 Tag,
140 Blob,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub enum ShortObjectIdResolution {
145 Missing,
146 Unique(ObjectId),
147 Ambiguous(Vec<ObjectId>),
148}
149
150impl ShortObjectIdResolution {
151 pub fn into_result(self, prefix: &str) -> Result<ObjectId> {
152 match self {
153 Self::Unique(oid) => Ok(oid),
154 Self::Missing => Err(GitError::not_found(format!("revision {prefix}"))),
155 Self::Ambiguous(_) => Err(short_object_id_ambiguous_error(prefix)),
156 }
157 }
158}
159
160#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct CommitMetadata {
169 pub oid: ObjectId,
170 pub parents: Vec<ObjectId>,
171 pub commit_time: i64,
174}
175
176pub fn commit_graph_tree_oid(
183 git_dir: &Path,
184 format: sley_core::ObjectFormat,
185 oid: &ObjectId,
186) -> Result<Option<ObjectId>> {
187 let mut graph = CommitGraphContext::load(git_dir, format);
188 match graph.direct_graph() {
189 DirectCommitGraph::Raw(graph) => graph.tree_oid(oid).or(Ok(None)),
190 DirectCommitGraph::Missing | DirectCommitGraph::Invalid(_) => Ok(None),
191 }
192}
193
194#[derive(Debug, Clone, PartialEq, Eq)]
201pub struct BisectTerms {
202 pub bad: String,
203 pub good: String,
204}
205
206impl Default for BisectTerms {
207 fn default() -> Self {
208 Self {
209 bad: "bad".to_string(),
210 good: "good".to_string(),
211 }
212 }
213}
214
215impl BisectTerms {
216 pub fn is_bad_ref(&self, ref_name: &str) -> bool {
217 bisect_ref_matches_term(ref_name, &self.bad)
218 }
219
220 pub fn is_good_ref(&self, ref_name: &str) -> bool {
221 bisect_ref_matches_term(ref_name, &self.good)
222 }
223}
224
225pub fn read_bisect_terms(git_dir: impl AsRef<Path>) -> Result<BisectTerms> {
226 let path = git_dir.as_ref().join("BISECT_TERMS");
227 let contents = match fs::read_to_string(&path) {
228 Ok(contents) => contents,
229 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
230 return Ok(BisectTerms::default());
231 }
232 Err(err) => return Err(GitError::Io(err.to_string())),
233 };
234 let mut lines = contents.lines();
235 let bad = match lines.next() {
236 Some(line) => line.to_string(),
237 None => String::new(),
238 };
239 let good = match lines.next() {
240 Some(line) => line.to_string(),
241 None => String::new(),
242 };
243 Ok(BisectTerms { bad, good })
244}
245
246fn bisect_ref_matches_term(ref_name: &str, term: &str) -> bool {
247 ref_name
248 .strip_prefix("refs/bisect/")
249 .is_some_and(|name| name.starts_with(term))
250}
251
252pub fn resolve_revision(
253 git_dir: impl AsRef<Path>,
254 format: ObjectFormat,
255 rev: &str,
256) -> Result<ObjectId> {
257 let git_dir = git_dir.as_ref();
258 let db = FileObjectDatabase::from_git_dir(git_dir, format);
259 resolve_revision_with_reader(git_dir, format, &db, rev)
260}
261
262pub fn resolve_revision_with_reader<R: ObjectReader>(
263 git_dir: &Path,
264 format: ObjectFormat,
265 reader: &R,
266 rev: &str,
267) -> Result<ObjectId> {
268 resolve_revision_inner(
269 git_dir,
270 format,
271 reader,
272 rev,
273 None,
274 ObjectDisambiguation::Any,
275 )
276}
277
278pub fn resolve_revision_with_config<R: ObjectReader>(
289 git_dir: &Path,
290 format: ObjectFormat,
291 reader: &R,
292 rev: &str,
293 config: &GitConfig,
294) -> Result<ObjectId> {
295 resolve_revision_inner(
296 git_dir,
297 format,
298 reader,
299 rev,
300 Some(config),
301 ObjectDisambiguation::Any,
302 )
303}
304
305pub fn resolve_revision_with_disambiguation(
312 git_dir: impl AsRef<Path>,
313 format: ObjectFormat,
314 rev: &str,
315 disambiguation: ObjectDisambiguation,
316) -> Result<ObjectId> {
317 let git_dir = git_dir.as_ref();
318 let db = FileObjectDatabase::from_git_dir(git_dir, format);
319 resolve_revision_inner(git_dir, format, &db, rev, None, disambiguation)
320}
321
322pub fn resolve_revision_commitish_with_reader<R: ObjectReader>(
328 git_dir: &Path,
329 format: ObjectFormat,
330 reader: &R,
331 rev: &str,
332) -> Result<ObjectId> {
333 resolve_revision_inner(
334 git_dir,
335 format,
336 reader,
337 rev,
338 None,
339 ObjectDisambiguation::Commitish,
340 )
341}
342
343pub fn resolve_revision_commitish_with_config<R: ObjectReader>(
347 git_dir: &Path,
348 format: ObjectFormat,
349 reader: &R,
350 rev: &str,
351 config: &GitConfig,
352) -> Result<ObjectId> {
353 resolve_revision_inner(
354 git_dir,
355 format,
356 reader,
357 rev,
358 Some(config),
359 ObjectDisambiguation::Commitish,
360 )
361}
362
363pub fn resolve_revision_commitish(
366 git_dir: impl AsRef<Path>,
367 format: ObjectFormat,
368 rev: &str,
369) -> Result<ObjectId> {
370 let git_dir = git_dir.as_ref();
371 let db = FileObjectDatabase::from_git_dir(git_dir, format);
372 resolve_revision_commitish_with_reader(git_dir, format, &db, rev)
373}
374
375pub fn resolve_revision_symbolic_full_name(
381 git_dir: &Path,
382 format: ObjectFormat,
383 rev: &str,
384) -> Result<Option<String>> {
385 resolve_revision_symbolic_full_name_inner(git_dir, format, rev, None)
386}
387
388pub fn resolve_revision_symbolic_full_name_with_config(
389 git_dir: &Path,
390 format: ObjectFormat,
391 rev: &str,
392 config: &GitConfig,
393) -> Result<Option<String>> {
394 resolve_revision_symbolic_full_name_inner(git_dir, format, rev, Some(config))
395}
396
397fn resolve_revision_symbolic_full_name_inner(
398 git_dir: &Path,
399 format: ObjectFormat,
400 rev: &str,
401 config: Option<&GitConfig>,
402) -> Result<Option<String>> {
403 if rev.len() == format.hex_len() && rev.bytes().all(|byte| byte.is_ascii_hexdigit()) {
404 return Ok(None);
405 }
406 if let Some(name) = resolve_at_selector_ref_name(git_dir, format, rev, config)? {
407 return Ok(Some(name));
408 }
409 let refs = FileRefStore::new(git_dir.to_path_buf(), format);
410 if rev == "HEAD" {
411 return refs.current_branch_ref();
412 }
413 if rev.starts_with("refs/") {
414 return Ok(refs.read_ref(rev)?.map(|_| rev.to_string()));
415 }
416 for candidate in [
417 format!("refs/{rev}"),
418 format!("refs/tags/{rev}"),
419 format!("refs/heads/{rev}"),
420 format!("refs/remotes/{rev}"),
421 format!("refs/remotes/{rev}/HEAD"),
422 ] {
423 if refs.read_ref(&candidate)?.is_some() {
424 return Ok(Some(candidate));
425 }
426 }
427 Err(GitError::not_found(format!("revision {rev}")))
428}
429
430fn resolve_revision_inner<R: ObjectReader>(
431 git_dir: &Path,
432 format: ObjectFormat,
433 reader: &R,
434 rev: &str,
435 config: Option<&GitConfig>,
436 disambiguation: ObjectDisambiguation,
437) -> Result<ObjectId> {
438 let parsed = RevisionSpecRef::parse(rev)?;
439 match parsed.kind() {
440 RevisionSpecKind::MessageSearch { text } => {
441 return search_commit_message_all(git_dir, format, reader, text);
442 }
443 RevisionSpecKind::IndexPath { stage, path } => {
444 return resolve_index_path(git_dir, format, reader, stage, path);
445 }
446 RevisionSpecKind::TreePath {
447 rev: rev_part,
448 path,
449 } => {
450 return resolve_rev_path(git_dir, format, reader, rev_part, path);
451 }
452 RevisionSpecKind::Revision { rev: _ } => {}
453 }
454 if let Some(oid) = resolve_at_selector(git_dir, format, rev, config)? {
459 return Ok(oid);
460 }
461 if let Some((base, suffix)) = split_revision_suffix(rev)? {
462 if base.is_empty() {
463 return Err(GitError::InvalidFormat(format!(
464 "revision {rev} has empty base"
465 )));
466 }
467 let base_disambiguation =
472 disambiguation_for_suffix(suffix).unwrap_or(ObjectDisambiguation::Any);
473 let base_oid =
474 resolve_revision_inner(git_dir, format, reader, base, config, base_disambiguation)?;
475 return apply_revision_suffix(git_dir, reader, format, &base_oid, suffix, rev);
476 }
477 resolve_revision_name(git_dir, format, rev, disambiguation)
478}
479
480fn disambiguation_for_suffix(suffix: RevisionSuffix<'_>) -> Option<ObjectDisambiguation> {
481 match suffix {
482 RevisionSuffix::Parent(_) | RevisionSuffix::FirstParent(_) | RevisionSuffix::Search(_) => {
483 Some(ObjectDisambiguation::Commitish)
484 }
485 RevisionSuffix::Peel(PeelKind::Object) => Some(ObjectDisambiguation::Any),
486 RevisionSuffix::Peel(PeelKind::AnyNonTag) => Some(ObjectDisambiguation::Any),
487 RevisionSuffix::Peel(PeelKind::Commit) => Some(ObjectDisambiguation::Commitish),
488 RevisionSuffix::Peel(PeelKind::Tree) => Some(ObjectDisambiguation::Treeish),
489 RevisionSuffix::Peel(PeelKind::Tag) => Some(ObjectDisambiguation::Tag),
490 RevisionSuffix::Peel(PeelKind::Blob) => Some(ObjectDisambiguation::Blob),
491 }
492}
493
494pub struct RevisionResolver<'a, R> {
495 git_dir: &'a Path,
496 format: ObjectFormat,
497 reader: &'a R,
498 config: Option<&'a GitConfig>,
499}
500
501impl<'a, R: ObjectReader> RevisionResolver<'a, R> {
502 pub fn new(git_dir: &'a Path, format: ObjectFormat, reader: &'a R) -> Self {
503 Self {
504 git_dir,
505 format,
506 reader,
507 config: None,
508 }
509 }
510
511 pub fn with_config(mut self, config: &'a GitConfig) -> Self {
515 self.config = Some(config);
516 self
517 }
518
519 pub fn resolve(&self, rev: &str) -> Result<ObjectId> {
520 resolve_revision_inner(
521 self.git_dir,
522 self.format,
523 self.reader,
524 rev,
525 self.config,
526 ObjectDisambiguation::Any,
527 )
528 }
529
530 pub fn peel_to_blob(&self, rev: &str) -> Result<ObjectId> {
531 let oid = self.resolve(rev)?;
532 peel_tags(self.reader, self.format, &oid)
533 }
534
535 pub fn peel_to_tree(&self, rev: &str) -> Result<ObjectId> {
536 let oid = self.resolve(rev)?;
537 peel_to_tree(self.reader, self.format, &oid)
538 }
539
540 pub fn peel_to_commit(&self, rev: &str) -> Result<ObjectId> {
541 let oid = self.resolve(rev)?;
542 peel_to_commit(self.reader, self.format, &oid)
543 }
544
545 pub fn resolve_path(&self, rev: &str, path: &str) -> Result<ResolvedTreePath> {
546 resolve_rev_path_entry(self.git_dir, self.format, self.reader, rev, path)
547 }
548
549 pub fn resolve_path_follow_symlinks(&self, rev: &str, path: &str) -> SymlinkedTreePath {
553 resolve_rev_path_follow_symlinks(self.git_dir, self.format, self.reader, rev, path)
554 }
555}
556
557fn resolve_revision_name(
558 git_dir: &Path,
559 format: sley_core::ObjectFormat,
560 rev: &str,
561 disambiguation: ObjectDisambiguation,
562) -> Result<ObjectId> {
563 if rev.len() == format.hex_len() && rev.bytes().all(|byte| byte.is_ascii_hexdigit()) {
564 return ObjectId::from_hex(format, rev);
565 }
566 let refs = FileRefStore::new(git_dir.to_path_buf(), format);
567 if let Some(oid) = resolve_revision_ref(&refs, rev)? {
568 return Ok(oid);
569 }
570 if rev.len() >= 4
575 && rev.len() < format.hex_len()
576 && rev.bytes().all(|byte| byte.is_ascii_hexdigit())
577 {
578 return resolve_short_object_id(git_dir, format, rev, disambiguation)?.into_result(rev);
579 }
580 if let Some(oid) = resolve_describe_name(git_dir, format, rev)? {
583 return Ok(oid);
584 }
585 Err(GitError::not_found(format!("revision {rev}")))
586}
587
588pub fn short_object_id_ambiguous_error(prefix: &str) -> GitError {
589 GitError::InvalidObjectId(format!("short object ID {prefix} is ambiguous"))
590}
591
592pub fn is_short_object_id_ambiguous_error(err: &GitError) -> bool {
593 matches!(err, GitError::InvalidObjectId(msg) if msg.starts_with("short object ID ") && msg.ends_with(" is ambiguous"))
594}
595
596pub fn resolve_short_object_id(
597 git_dir: &Path,
598 format: ObjectFormat,
599 prefix: &str,
600 disambiguation: ObjectDisambiguation,
601) -> Result<ShortObjectIdResolution> {
602 let db = FileObjectDatabase::from_git_dir(git_dir, format);
603 resolve_short_object_id_with_reader(git_dir, format, &db, prefix, disambiguation)
604}
605
606pub fn object_ids_with_prefix(
607 git_dir: &Path,
608 format: ObjectFormat,
609 prefix: &str,
610) -> Result<Vec<ObjectId>> {
611 FileObjectDatabase::from_git_dir(git_dir, format).object_ids_with_prefix(prefix)
612}
613
614pub fn resolve_short_object_id_with_reader<R: ObjectReader>(
615 git_dir: &Path,
616 format: ObjectFormat,
617 reader: &R,
618 prefix: &str,
619 disambiguation: ObjectDisambiguation,
620) -> Result<ShortObjectIdResolution> {
621 let db = FileObjectDatabase::from_git_dir(git_dir, format);
622 let candidates = db.object_ids_with_prefix(prefix)?;
623 if candidates.is_empty() {
624 return Ok(ShortObjectIdResolution::Missing);
625 }
626 if disambiguation == ObjectDisambiguation::Any {
627 return Ok(match candidates.len() {
628 1 => ShortObjectIdResolution::Unique(candidates[0]),
629 _ => ShortObjectIdResolution::Ambiguous(candidates),
630 });
631 }
632 let mut accepted = Vec::new();
633 for oid in &candidates {
634 if short_object_id_matches_type(reader, format, oid, disambiguation) {
635 accepted.push(*oid);
636 }
637 }
638 Ok(match accepted.len() {
639 1 => ShortObjectIdResolution::Unique(accepted[0]),
640 0 => ShortObjectIdResolution::Ambiguous(candidates),
641 _ => ShortObjectIdResolution::Ambiguous(accepted),
642 })
643}
644
645fn short_object_id_matches_type<R: ObjectReader>(
646 reader: &R,
647 format: ObjectFormat,
648 oid: &ObjectId,
649 disambiguation: ObjectDisambiguation,
650) -> bool {
651 match disambiguation {
652 ObjectDisambiguation::Any => true,
653 ObjectDisambiguation::Commit => reader
654 .read_object(oid)
655 .is_ok_and(|object| object.object_type == ObjectType::Commit),
656 ObjectDisambiguation::Commitish => peel_to_commit(reader, format, oid).is_ok(),
657 ObjectDisambiguation::Tree => reader
658 .read_object(oid)
659 .is_ok_and(|object| object.object_type == ObjectType::Tree),
660 ObjectDisambiguation::Treeish => peel_to_tree(reader, format, oid).is_ok(),
661 ObjectDisambiguation::Tag => reader
662 .read_object(oid)
663 .is_ok_and(|object| object.object_type == ObjectType::Tag),
664 ObjectDisambiguation::Blob => peel_to_blob(reader, format, oid).is_ok(),
665 }
666}
667
668pub fn ambiguous_short_object_id_hint(
669 git_dir: &Path,
670 format: ObjectFormat,
671 prefix: &str,
672 disambiguation: ObjectDisambiguation,
673) -> Result<Vec<String>> {
674 let db = FileObjectDatabase::from_git_dir(git_dir, format);
675 let mut candidates = db.object_ids_with_prefix(prefix)?;
676 candidates.sort_by(|left, right| {
677 let left_type = ambiguous_candidate_type_for_sort(&db, left);
678 let right_type = ambiguous_candidate_type_for_sort(&db, right);
679 ambiguous_type_sort_key(left_type)
680 .cmp(&ambiguous_type_sort_key(right_type))
681 .then_with(|| left.to_hex().cmp(&right.to_hex()))
682 });
683 let mut out = Vec::new();
684 for oid in candidates {
685 if disambiguation != ObjectDisambiguation::Any
686 && !short_object_id_matches_type(&db, format, &oid, disambiguation)
687 {
688 continue;
689 }
690 out.push(ambiguous_short_object_id_line(&db, format, &oid)?);
691 }
692 if out.is_empty() && disambiguation != ObjectDisambiguation::Any {
693 for oid in db.object_ids_with_prefix(prefix)? {
694 out.push(ambiguous_short_object_id_line(&db, format, &oid)?);
695 }
696 }
697 Ok(out)
698}
699
700fn ambiguous_candidate_type_for_sort(
701 db: &FileObjectDatabase,
702 oid: &ObjectId,
703) -> Option<ObjectType> {
704 match db.read_object_header(oid) {
705 Ok(Some((object_type, _))) => Some(object_type),
706 Err(GitError::InvalidObject(message)) if message.starts_with("unable to unpack ") => {
707 eprintln!("error: {message}");
708 None
709 }
710 Ok(None) | Err(_) => None,
711 }
712}
713
714fn ambiguous_type_sort_key(object_type: Option<ObjectType>) -> u8 {
715 match object_type {
716 None => 0,
717 Some(ObjectType::Tag) => 1,
718 Some(ObjectType::Commit) => 2,
719 Some(ObjectType::Tree) => 3,
720 Some(ObjectType::Blob) => 4,
721 }
722}
723
724fn ambiguous_short_object_id_line(
725 db: &FileObjectDatabase,
726 format: ObjectFormat,
727 oid: &ObjectId,
728) -> Result<String> {
729 let abbrev = unique_object_abbrev(db, oid)?;
730 let object_type = match db.read_object_header(oid) {
731 Ok(Some((object_type, _))) => object_type,
732 Err(GitError::InvalidObject(message)) if message.starts_with("unknown object type") => {
733 return Err(GitError::InvalidObject(message));
734 }
735 Err(GitError::InvalidObject(message)) if message.starts_with("unable to unpack ") => {
736 eprintln!("error: {message}");
737 return Ok(format!("{abbrev} [bad object]"));
738 }
739 Ok(None) | Err(_) => return Ok(format!("{abbrev} [bad object]")),
740 };
741 if matches!(object_type, ObjectType::Tree | ObjectType::Blob) {
742 return Ok(format!("{abbrev} {}", object_type.as_str()));
743 }
744 let object = match db.read_object(oid) {
745 Ok(object) => object,
746 Err(GitError::InvalidObject(message)) if message.starts_with("unknown object type") => {
747 return Err(GitError::InvalidObject(message));
748 }
749 Err(GitError::InvalidObject(message)) if message.starts_with("unable to unpack ") => {
750 eprintln!("error: {message}");
751 return Ok(format!("{abbrev} [bad object]"));
752 }
753 Err(_) => return Ok(format!("{abbrev} [bad object]")),
754 };
755 Ok(match object_type {
756 ObjectType::Commit => {
757 let commit = Commit::parse_ref(format, &object.body)?;
758 let subject = first_message_line(commit.message);
759 match short_date_from_ident(commit.committer) {
760 Some(date) if !subject.is_empty() => format!("{abbrev} commit {date} - {subject}"),
761 Some(date) => format!("{abbrev} commit {date} - "),
762 None if !subject.is_empty() => format!("{abbrev} commit - {subject}"),
763 None => format!("{abbrev} commit - "),
764 }
765 }
766 ObjectType::Tag => match Tag::parse_ref(format, &object.body) {
767 Ok(tag) => {
768 let name = String::from_utf8_lossy(tag.name);
769 match tag.tagger.and_then(short_date_from_ident) {
770 Some(date) => format!("{abbrev} tag {date} - {name}"),
771 None => format!("{abbrev} tag - {name}"),
772 }
773 }
774 Err(_) => format!("{abbrev} [bad tag, could not parse it]"),
775 },
776 ObjectType::Tree => format!("{abbrev} tree"),
777 ObjectType::Blob => format!("{abbrev} blob"),
778 })
779}
780
781fn unique_object_abbrev(db: &FileObjectDatabase, oid: &ObjectId) -> Result<String> {
782 let hex = oid.to_hex();
783 let mut width = 7.min(hex.len());
784 while width < hex.len() {
785 match db.resolve_prefix(&hex[..width])? {
786 ObjectPrefixResolution::Ambiguous(_) => width += 1,
787 _ => break,
788 }
789 }
790 Ok(hex[..width].to_string())
791}
792
793fn first_message_line(message: &[u8]) -> String {
794 let line = message
795 .split(|byte| *byte == b'\n')
796 .next()
797 .unwrap_or_default();
798 String::from_utf8_lossy(line).into_owned()
799}
800
801fn short_date_from_ident(ident: &[u8]) -> Option<String> {
802 let signature = sley_core::Signature::from_ident_line(ident)?;
803 short_date_from_timestamp(signature.time.seconds)
804}
805
806fn short_date_from_timestamp(timestamp: i64) -> Option<String> {
807 let days = timestamp.div_euclid(86_400);
808 let (year, month, day) = civil_from_days_for_short_date(days)?;
809 Some(format!("{year:04}-{month:02}-{day:02}"))
810}
811
812fn civil_from_days_for_short_date(days: i64) -> Option<(i64, u32, u32)> {
813 let z = days.checked_add(719_468)?;
814 let era = if z >= 0 { z } else { z - 146_096 }.div_euclid(146_097);
815 let doe = z - era * 146_097;
816 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096).div_euclid(365);
817 let y = yoe + era * 400;
818 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
819 let mp = (5 * doy + 2).div_euclid(153);
820 let day = doy - (153 * mp + 2).div_euclid(5) + 1;
821 let month = mp + if mp < 10 { 3 } else { -9 };
822 let year = y + i64::from(month <= 2);
823 Some((year, u32::try_from(month).ok()?, u32::try_from(day).ok()?))
824}
825
826fn resolve_describe_name(
831 git_dir: &Path,
832 format: sley_core::ObjectFormat,
833 rev: &str,
834) -> Result<Option<ObjectId>> {
835 let bytes = rev.as_bytes();
836 let mut idx = bytes.len();
839 while idx >= 2 {
840 idx -= 1;
841 let ch = bytes[idx];
842 if ch.is_ascii_hexdigit() {
843 continue;
844 }
845 if ch == b'g' && idx >= 1 && bytes[idx - 1] == b'-' {
846 let hex = &rev[idx + 1..];
847 if hex.len() >= 4
848 && hex.len() < format.hex_len()
849 && hex.bytes().all(|byte| byte.is_ascii_hexdigit())
850 && let ShortObjectIdResolution::Unique(oid) =
851 resolve_short_object_id(git_dir, format, hex, ObjectDisambiguation::Commit)?
852 {
853 return Ok(Some(oid));
854 }
855 }
856 break;
857 }
858 Ok(None)
859}
860
861fn resolve_revision_ref(refs: &FileRefStore, rev: &str) -> Result<Option<ObjectId>> {
862 let mut candidates = Vec::new();
863 if rev == "HEAD" {
864 candidates.push("HEAD".to_string());
865 } else if rev.starts_with("refs/") {
866 candidates.push(rev.to_string());
867 } else {
868 let refs_name = format!("refs/{rev}");
869 if refs.read_ref(&refs_name)?.is_some() {
870 candidates.push(refs_name);
874 }
875 let tag_name = format!("refs/tags/{rev}");
876 if refs.read_ref(&tag_name)?.is_some() {
877 candidates.push(tag_name);
878 }
879 let head_name = format!("refs/heads/{rev}");
880 if refs.read_ref(&head_name)?.is_some() {
881 candidates.push(head_name);
882 }
883 let remote_name = format!("refs/remotes/{rev}");
884 if refs.read_ref(&remote_name)?.is_some() {
885 candidates.push(remote_name);
886 }
887 let remote_head_name = format!("refs/remotes/{rev}/HEAD");
888 if refs.read_ref(&remote_head_name)?.is_some() {
889 candidates.push(remote_head_name);
890 }
891 if validate_ref_name_for_read(rev).is_ok() {
892 candidates.push(rev.to_string());
893 }
894 }
895 for candidate in candidates {
896 if let Some(oid) = resolve_revision_ref_candidate(refs, &candidate)? {
897 return Ok(Some(oid));
898 }
899 }
900 Ok(None)
901}
902
903fn resolve_revision_ref_candidate(refs: &FileRefStore, name: &str) -> Result<Option<ObjectId>> {
904 let mut current = name.to_string();
905 for _ in 0..16 {
906 match refs.read_ref(¤t)? {
907 Some(RefTarget::Direct(oid)) => return Ok(Some(oid)),
908 Some(RefTarget::Symbolic(target)) => {
909 if validate_symref_target(&target).is_err() {
910 eprintln!("warning: ignoring dangling symref {name}");
911 return Ok(None);
912 }
913 current = target;
914 }
915 None => return Ok(None),
916 }
917 }
918 Ok(None)
919}
920
921fn resolve_at_selector(
940 git_dir: &Path,
941 format: sley_core::ObjectFormat,
942 rev: &str,
943 config: Option<&GitConfig>,
944) -> Result<Option<ObjectId>> {
945 if rev == "@" {
947 let refs = FileRefStore::new(git_dir.to_path_buf(), format);
948 return match resolve_revision_ref(&refs, "HEAD")? {
949 Some(oid) => Ok(Some(oid)),
950 None => Err(GitError::not_found("revision @")),
951 };
952 }
953
954 let Some(open) = rev.rfind("@{") else {
956 return Ok(None);
957 };
958 let Some(inner) = rev.strip_suffix('}') else {
959 return Ok(None);
960 };
961 let inner = &inner[open + 2..];
963 if inner.contains('}') {
964 return Ok(None);
965 }
966 let base = &rev[..open];
967 let refs = FileRefStore::new(git_dir.to_path_buf(), format);
968
969 if let Some(rest) = inner.strip_prefix('-') {
972 if !base.is_empty() {
973 return Err(GitError::InvalidFormat(format!(
974 "invalid revision selector {rev}"
975 )));
976 }
977 let count = parse_at_count(rev, rest)?;
978 return Ok(Some(resolve_previous_checkout(
979 git_dir, format, count, rev,
980 )?));
981 }
982
983 if inner.eq_ignore_ascii_case("u") || inner.eq_ignore_ascii_case("upstream") {
984 let upstream = resolve_upstream_ref(git_dir, format, base, false, rev, config)?;
985 return match resolve_revision_ref(&refs, &upstream.refname)? {
986 Some(oid) => Ok(Some(oid)),
987 None => Err(upstream.missing_error(rev)),
988 };
989 }
990 if inner.eq_ignore_ascii_case("push") {
991 let upstream = resolve_upstream_ref(git_dir, format, base, true, rev, config)?;
992 return match resolve_revision_ref(&refs, &upstream.refname)? {
993 Some(oid) => Ok(Some(oid)),
994 None => Err(upstream.missing_error(rev)),
995 };
996 }
997 if inner.bytes().all(|byte| byte.is_ascii_digit()) {
998 let count = parse_at_count(rev, inner)?;
999 return Ok(Some(resolve_reflog_nth(
1000 git_dir, format, base, count, rev, config,
1001 )?));
1002 }
1003
1004 Ok(Some(resolve_reflog_date(
1005 git_dir, format, base, inner, rev, config,
1006 )?))
1007}
1008
1009fn resolve_at_selector_ref_name(
1010 git_dir: &Path,
1011 format: sley_core::ObjectFormat,
1012 rev: &str,
1013 config: Option<&GitConfig>,
1014) -> Result<Option<String>> {
1015 let Some(open) = rev.rfind("@{") else {
1016 return Ok(None);
1017 };
1018 let Some(inner) = rev.strip_suffix('}') else {
1019 return Ok(None);
1020 };
1021 let inner = &inner[open + 2..];
1022 if inner.contains('}') {
1023 return Ok(None);
1024 }
1025 let base = &rev[..open];
1026 if let Some(prior) = parse_prior_checkout_selector(rev)? {
1027 let Some(branch) = nth_prior_checkout_branch_name(git_dir, format, prior)? else {
1028 return Err(GitError::not_found(format!(
1029 "not enough previous checkouts to resolve {rev}"
1030 )));
1031 };
1032 return Ok(Some(format!("refs/heads/{branch}")));
1033 }
1034 if inner.eq_ignore_ascii_case("u") || inner.eq_ignore_ascii_case("upstream") {
1035 return Ok(Some(
1036 resolve_upstream_ref(git_dir, format, base, false, rev, config)?.refname,
1037 ));
1038 }
1039 if inner.eq_ignore_ascii_case("push") {
1040 return Ok(Some(
1041 resolve_upstream_ref(git_dir, format, base, true, rev, config)?.refname,
1042 ));
1043 }
1044 if inner.bytes().all(|byte| byte.is_ascii_digit()) || !inner.starts_with('-') {
1045 let refs = FileRefStore::new(git_dir.to_path_buf(), format);
1046 return Ok(Some(reflog_ref_name_for_base(
1047 git_dir, format, &refs, base, config,
1048 )?));
1049 }
1050 Ok(None)
1051}
1052
1053fn parse_at_count(rev: &str, text: &str) -> Result<usize> {
1055 if text.is_empty() || !text.bytes().all(|byte| byte.is_ascii_digit()) {
1056 return Err(GitError::InvalidFormat(format!(
1057 "invalid revision selector {rev}"
1058 )));
1059 }
1060 text.parse::<usize>()
1061 .map_err(|_| GitError::InvalidFormat(format!("invalid revision selector {rev}")))
1062}
1063
1064fn parse_prior_checkout_selector(rev: &str) -> Result<Option<usize>> {
1065 let Some(inner) = rev
1066 .strip_prefix("@{-")
1067 .and_then(|rest| rest.strip_suffix('}'))
1068 else {
1069 return Ok(None);
1070 };
1071 if !inner.bytes().all(|byte| byte.is_ascii_digit()) {
1072 return Ok(None);
1073 }
1074 Ok(Some(parse_at_count(rev, inner)?))
1075}
1076
1077fn is_reflog_count_or_date_selector(rev: &str) -> bool {
1078 let Some(open) = rev.rfind("@{") else {
1079 return false;
1080 };
1081 let Some(inner) = rev.strip_suffix('}') else {
1082 return false;
1083 };
1084 let inner = &inner[open + 2..];
1085 !(inner.eq_ignore_ascii_case("u")
1086 || inner.eq_ignore_ascii_case("upstream")
1087 || inner.eq_ignore_ascii_case("push")
1088 || inner.starts_with('-'))
1089}
1090
1091fn reflog_ref_name(refs: &FileRefStore, base: &str) -> String {
1103 if base == "HEAD" {
1104 return "HEAD".to_string();
1105 }
1106 if base.starts_with("refs/") {
1107 return base.to_string();
1108 }
1109 for candidate in reflog_dwim_candidates(base) {
1110 if reflog_exists(refs, &candidate) {
1111 return candidate;
1112 }
1113 }
1114 format!("refs/heads/{base}")
1115}
1116
1117fn reflog_ref_name_for_base(
1118 git_dir: &Path,
1119 format: sley_core::ObjectFormat,
1120 refs: &FileRefStore,
1121 base: &str,
1122 config: Option<&GitConfig>,
1123) -> Result<String> {
1124 if base.is_empty() {
1125 return Ok(refs
1126 .current_branch_ref()?
1127 .unwrap_or_else(|| "HEAD".to_string()));
1128 }
1129 if base == "@" {
1130 return Ok("HEAD".to_string());
1131 }
1132 if let Some(prior) = parse_prior_checkout_selector(base)? {
1133 let Some(branch) = nth_prior_checkout_branch_name(git_dir, format, prior)? else {
1134 return Err(GitError::not_found(format!(
1135 "not enough previous checkouts to resolve {base}"
1136 )));
1137 };
1138 return Ok(reflog_ref_name(refs, &branch));
1139 }
1140 if is_reflog_count_or_date_selector(base) {
1141 return Err(GitError::InvalidFormat(format!(
1142 "invalid revision selector {base}"
1143 )));
1144 }
1145 if base.contains("@{")
1146 && let Some(name) = resolve_at_selector_ref_name(git_dir, format, base, config)?
1147 {
1148 return Ok(name);
1149 }
1150 if base.contains("@{") {
1151 return Err(GitError::InvalidFormat(format!(
1152 "invalid revision selector {base}"
1153 )));
1154 }
1155 Ok(reflog_ref_name(refs, base))
1156}
1157
1158fn reflog_dwim_candidates(base: &str) -> [String; 6] {
1160 [
1161 base.to_string(),
1162 format!("refs/{base}"),
1163 format!("refs/tags/{base}"),
1164 format!("refs/heads/{base}"),
1165 format!("refs/remotes/{base}"),
1166 format!("refs/remotes/{base}/HEAD"),
1167 ]
1168}
1169
1170fn reflog_exists(refs: &FileRefStore, name: &str) -> bool {
1174 refs.reflog_exists(name).unwrap_or(false)
1175}
1176
1177fn resolve_reflog_nth(
1184 git_dir: &Path,
1185 format: sley_core::ObjectFormat,
1186 base: &str,
1187 n: usize,
1188 rev: &str,
1189 config: Option<&GitConfig>,
1190) -> Result<ObjectId> {
1191 let refs = FileRefStore::new(git_dir.to_path_buf(), format);
1192 let ref_name = reflog_ref_name_for_base(git_dir, format, &refs, base, config)?;
1193 let display_name = reflog_display_name_for_ref(base, &ref_name);
1194 let entries = refs.read_reflog(&ref_name)?;
1195 if entries.is_empty() {
1196 if n == 0
1197 && refs.reflog_exists(&ref_name)?
1198 && let Some(oid) = resolve_revision_ref_candidate(&refs, &ref_name)?
1199 {
1200 return Ok(oid);
1201 }
1202 return Err(GitError::not_found(format!(
1203 "no reflog for '{}' to resolve {rev}",
1204 display_name
1205 )));
1206 }
1207 let len = entries.len();
1209 if n >= len {
1210 if n == len && !object_id_is_null(&entries[0].old_oid) {
1211 return Ok(entries[0].old_oid);
1212 }
1213 return Err(GitError::not_found(format!(
1214 "log for '{}' only has {len} entries",
1215 display_name
1216 )));
1217 }
1218 Ok(entries[len - 1 - n].new_oid)
1219}
1220
1221fn resolve_reflog_date(
1222 git_dir: &Path,
1223 format: sley_core::ObjectFormat,
1224 base: &str,
1225 date: &str,
1226 rev: &str,
1227 config: Option<&GitConfig>,
1228) -> Result<ObjectId> {
1229 let cutoff = parse_reflog_selector_date(date)
1230 .ok_or_else(|| GitError::Unsupported(format!("revision selector @{{{date}}}")))?;
1231 let refs = FileRefStore::new(git_dir.to_path_buf(), format);
1232 let ref_name = reflog_ref_name_for_base(git_dir, format, &refs, base, config)?;
1233 let display_name = reflog_display_name_for_ref(base, &ref_name);
1234 let entries = refs.read_reflog(&ref_name)?;
1235 if entries.is_empty() {
1236 return Err(GitError::not_found(format!(
1237 "no reflog for '{}' to resolve {rev}",
1238 display_name
1239 )));
1240 }
1241 for entry in entries.iter().rev() {
1242 if reflog_entry_timestamp(entry)? <= cutoff {
1243 return Ok(entry.new_oid);
1244 }
1245 }
1246 Ok(entries[0].new_oid)
1247}
1248
1249fn reflog_entry_timestamp(entry: &ReflogEntry) -> Result<i64> {
1250 entry.timestamp_seconds()
1251}
1252
1253fn object_id_is_null(oid: &ObjectId) -> bool {
1254 oid.as_bytes().iter().all(|byte| *byte == 0)
1255}
1256
1257fn parse_reflog_selector_date(value: &str) -> Option<i64> {
1258 if value == "now" {
1259 return std::time::SystemTime::now()
1260 .duration_since(std::time::UNIX_EPOCH)
1261 .ok()
1262 .and_then(|duration| i64::try_from(duration.as_secs()).ok());
1263 }
1264 if let Some(years) = value.strip_suffix(".year.ago") {
1265 let years = years.parse::<i64>().ok()?;
1266 let now = std::time::SystemTime::now()
1267 .duration_since(std::time::UNIX_EPOCH)
1268 .ok()?
1269 .as_secs();
1270 let now = i64::try_from(now).ok()?;
1271 return Some(now.saturating_sub(years.saturating_mul(365 * 86_400)));
1272 }
1273 let mut parts = value.split_ascii_whitespace();
1274 let _weekday = parts.next()?;
1275 let month = parse_reflog_month(parts.next()?)?;
1276 let day = parts.next()?.parse::<u32>().ok()?;
1277 let time = parts.next()?;
1278 let year = parts.next()?.parse::<i64>().ok()?;
1279 let tz = parts.next()?;
1280 if parts.next().is_some() {
1281 return None;
1282 }
1283 let mut time_parts = time.split(':');
1284 let hour = time_parts.next()?.parse::<i64>().ok()?;
1285 let minute = time_parts.next()?.parse::<i64>().ok()?;
1286 let second = time_parts.next()?.parse::<i64>().ok()?;
1287 if time_parts.next().is_some() || hour > 23 || minute > 59 || second > 60 {
1288 return None;
1289 }
1290 let offset = parse_reflog_timezone(tz)?;
1291 Some(days_from_civil(year, month, day)? * 86_400 + hour * 3_600 + minute * 60 + second - offset)
1292}
1293
1294fn parse_reflog_month(value: &str) -> Option<u32> {
1295 match value {
1296 "Jan" => Some(1),
1297 "Feb" => Some(2),
1298 "Mar" => Some(3),
1299 "Apr" => Some(4),
1300 "May" => Some(5),
1301 "Jun" => Some(6),
1302 "Jul" => Some(7),
1303 "Aug" => Some(8),
1304 "Sep" => Some(9),
1305 "Oct" => Some(10),
1306 "Nov" => Some(11),
1307 "Dec" => Some(12),
1308 _ => None,
1309 }
1310}
1311
1312fn parse_reflog_timezone(value: &str) -> Option<i64> {
1313 let bytes = value.as_bytes();
1314 if bytes.len() != 5 || (bytes[0] != b'+' && bytes[0] != b'-') {
1315 return None;
1316 }
1317 let hours = value[1..3].parse::<i64>().ok()?;
1318 let minutes = value[3..5].parse::<i64>().ok()?;
1319 if hours > 23 || minutes > 59 {
1320 return None;
1321 }
1322 let seconds = hours * 3_600 + minutes * 60;
1323 if bytes[0] == b'-' {
1324 Some(-seconds)
1325 } else {
1326 Some(seconds)
1327 }
1328}
1329
1330fn days_from_civil(year: i64, month: u32, day: u32) -> Option<i64> {
1331 if !(1..=12).contains(&month) || day == 0 || day > days_in_month(year, month) {
1332 return None;
1333 }
1334 let year = year - i64::from(month <= 2);
1335 let era = if year >= 0 { year } else { year - 399 } / 400;
1336 let yoe = year - era * 400;
1337 let month = i64::from(month);
1338 let day = i64::from(day);
1339 let doy = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + day - 1;
1340 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
1341 Some(era * 146_097 + doe - 719_468)
1342}
1343
1344fn days_in_month(year: i64, month: u32) -> u32 {
1345 match month {
1346 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
1347 4 | 6 | 9 | 11 => 30,
1348 2 if is_leap_year(year) => 29,
1349 2 => 28,
1350 _ => 0,
1351 }
1352}
1353
1354fn is_leap_year(year: i64) -> bool {
1355 (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
1356}
1357
1358fn reflog_display_name(base: &str) -> String {
1361 if base.is_empty() {
1362 "HEAD".to_string()
1363 } else {
1364 base.to_string()
1365 }
1366}
1367
1368fn reflog_display_name_for_ref(base: &str, ref_name: &str) -> String {
1369 if base.is_empty()
1370 && let Some(branch) = ref_name.strip_prefix("refs/heads/")
1371 {
1372 return branch.to_string();
1373 }
1374 if base == "@" {
1375 return "HEAD".to_string();
1376 }
1377 reflog_display_name(base)
1378}
1379
1380fn resolve_previous_checkout(
1386 git_dir: &Path,
1387 format: sley_core::ObjectFormat,
1388 n: usize,
1389 rev: &str,
1390) -> Result<ObjectId> {
1391 if n == 0 {
1392 return Err(GitError::InvalidFormat(format!(
1393 "invalid revision selector {rev}"
1394 )));
1395 }
1396 let refs = FileRefStore::new(git_dir.to_path_buf(), format);
1397 let entries = refs.read_reflog("HEAD")?;
1398 let mut seen = 0usize;
1399 for entry in entries.iter().rev() {
1400 let Some(from) = checkout_move_source(&entry.message) else {
1401 continue;
1402 };
1403 seen += 1;
1404 if seen == n {
1405 let from = from.to_string();
1406 return resolve_revision_name(git_dir, format, &from, ObjectDisambiguation::Any)
1407 .map_err(|_| {
1408 GitError::not_found(format!(
1409 "could not resolve previous branch '{from}' for {rev}"
1410 ))
1411 });
1412 }
1413 }
1414 Err(GitError::not_found(format!(
1415 "not enough previous checkouts to resolve {rev}"
1416 )))
1417}
1418
1419pub fn nth_prior_checkout_branch_name(
1427 git_dir: &Path,
1428 format: sley_core::ObjectFormat,
1429 n: usize,
1430) -> Result<Option<String>> {
1431 if n == 0 {
1432 return Ok(None);
1433 }
1434 let refs = FileRefStore::new(git_dir.to_path_buf(), format);
1435 let entries = refs.read_reflog("HEAD")?;
1436 let mut seen = 0usize;
1437 for entry in entries.iter().rev() {
1438 let Some(from) = checkout_move_source(&entry.message) else {
1439 continue;
1440 };
1441 seen += 1;
1442 if seen == n {
1443 return Ok(Some(from.to_string()));
1444 }
1445 }
1446 Ok(None)
1447}
1448
1449fn checkout_move_source(message: &[u8]) -> Option<&str> {
1450 let message = std::str::from_utf8(message).ok()?;
1451 let rest = message.strip_prefix("checkout: moving from ")?;
1452 let (from, _to) = rest.split_once(" to ")?;
1454 Some(from)
1455}
1456
1457struct UpstreamRef {
1458 refname: String,
1459 merge: String,
1460}
1461
1462impl UpstreamRef {
1463 fn missing_error(&self, _rev: &str) -> GitError {
1464 GitError::not_found(format!(
1465 "upstream branch '{}' not stored as a remote-tracking branch",
1466 self.merge
1467 ))
1468 }
1469}
1470
1471fn resolve_upstream_ref(
1480 git_dir: &Path,
1481 format: sley_core::ObjectFormat,
1482 base: &str,
1483 push: bool,
1484 rev: &str,
1485 config: Option<&GitConfig>,
1486) -> Result<UpstreamRef> {
1487 let refs = FileRefStore::new(git_dir.to_path_buf(), format);
1488 let branch = if base.is_empty() || base == "HEAD" || base == "@" {
1489 refs.current_branch()?
1490 .ok_or_else(|| GitError::InvalidFormat("HEAD does not point to a branch".to_string()))?
1491 } else if let Some(prior) = parse_prior_checkout_selector(base)? {
1492 nth_prior_checkout_branch_name(git_dir, format, prior)?.ok_or_else(|| {
1493 GitError::not_found(format!("not enough previous checkouts to resolve {base}"))
1494 })?
1495 } else if base.starts_with("refs/") || base.contains("@{") {
1496 return Err(GitError::InvalidFormat(format!(
1497 "{base} is not a branch, cannot resolve {rev}"
1498 )));
1499 } else {
1500 base.to_string()
1501 };
1502 if refs.read_ref(&format!("refs/heads/{branch}"))?.is_none() {
1503 return Err(GitError::not_found(format!("no such branch: '{branch}'")));
1504 }
1505
1506 let owned_config;
1510 let config = match config {
1511 Some(config) => config,
1512 None => {
1513 owned_config = read_repo_config(git_dir)?;
1514 &owned_config
1515 }
1516 };
1517 if push {
1521 return branch_get_push(&branch, config);
1522 }
1523 let merge = config
1524 .get("branch", Some(&branch), "merge")
1525 .ok_or_else(|| {
1526 GitError::not_found(format!("no upstream configured for branch '{branch}'"))
1527 })?;
1528 let short = merge.strip_prefix("refs/heads/").unwrap_or(merge);
1529 let remote = config
1530 .get("branch", Some(&branch), "remote")
1531 .ok_or_else(|| GitError::not_found(format!("no upstream remote for branch '{branch}'")))?;
1532
1533 let refname = if remote == "." {
1534 merge.to_string()
1535 } else {
1536 format!("refs/remotes/{remote}/{short}")
1537 };
1538 Ok(UpstreamRef {
1539 refname,
1540 merge: merge.to_string(),
1541 })
1542}
1543
1544fn branch_get_push(branch: &str, config: &GitConfig) -> Result<UpstreamRef> {
1548 let merge = config
1549 .get("branch", Some(branch), "merge")
1550 .map(str::to_string);
1551 let pushremote = config
1552 .get("branch", Some(branch), "pushRemote")
1553 .or_else(|| config.get("remote", None, "pushDefault"))
1554 .or_else(|| config.get("branch", Some(branch), "remote"))
1555 .ok_or_else(|| GitError::not_found(format!("branch '{branch}' has no remote for pushing")))?
1556 .to_string();
1557 let branch_refname = format!("refs/heads/{branch}");
1558
1559 let upstream_ref = |refname: String| UpstreamRef {
1560 refname,
1561 merge: merge.clone().unwrap_or_default(),
1562 };
1563
1564 let push_refspecs: Vec<&str> = config
1567 .get_all("remote", Some(&pushremote), "push")
1568 .into_iter()
1569 .flatten()
1570 .collect();
1571 if !push_refspecs.is_empty() {
1572 let dst = apply_refspecs(&push_refspecs, &branch_refname).ok_or_else(|| {
1573 GitError::not_found(format!(
1574 "push refspecs for '{pushremote}' do not include '{branch}'"
1575 ))
1576 })?;
1577 return Ok(upstream_ref(tracking_for_push_dest(
1578 config,
1579 &pushremote,
1580 &dst,
1581 )?));
1582 }
1583
1584 match config.get("push", None, "default").unwrap_or("simple") {
1585 "nothing" => Err(GitError::not_found(
1586 "push has no destination (push.default is 'nothing')".to_string(),
1587 )),
1588 "matching" | "current" => Ok(upstream_ref(tracking_for_push_dest(
1589 config,
1590 &pushremote,
1591 &branch_refname,
1592 )?)),
1593 "upstream" | "tracking" => Ok(upstream_ref(branch_get_upstream_refname(
1594 config,
1595 branch,
1596 merge.as_deref(),
1597 )?)),
1598 _ => {
1601 let up = branch_get_upstream_refname(config, branch, merge.as_deref())?;
1602 let cur = tracking_for_push_dest(config, &pushremote, &branch_refname)?;
1603 if cur != up {
1604 return Err(GitError::not_found(
1605 "cannot resolve 'simple' push to a single destination".to_string(),
1606 ));
1607 }
1608 Ok(upstream_ref(cur))
1609 }
1610 }
1611}
1612
1613fn branch_get_upstream_refname(
1617 config: &GitConfig,
1618 branch: &str,
1619 merge: Option<&str>,
1620) -> Result<String> {
1621 let merge = merge.filter(|merge| !merge.is_empty()).ok_or_else(|| {
1622 GitError::not_found(format!("no upstream configured for branch '{branch}'"))
1623 })?;
1624 let remote = config
1625 .get("branch", Some(branch), "remote")
1626 .ok_or_else(|| {
1627 GitError::not_found(format!("no upstream configured for branch '{branch}'"))
1628 })?;
1629 if remote == "." {
1630 return Ok(merge.to_string());
1631 }
1632 tracking_for_push_dest(config, remote, merge)
1633}
1634
1635fn tracking_for_push_dest(config: &GitConfig, remote: &str, refname: &str) -> Result<String> {
1645 let fetch_refspecs: Vec<&str> = config
1646 .get_all("remote", Some(remote), "fetch")
1647 .into_iter()
1648 .flatten()
1649 .collect();
1650 if let Some(dst) = apply_refspecs(&fetch_refspecs, refname) {
1651 return Ok(dst);
1652 }
1653 let short = refname.strip_prefix("refs/heads/").unwrap_or(refname);
1654 Ok(format!("refs/remotes/{remote}/{short}"))
1655}
1656
1657fn apply_refspecs(refspecs: &[&str], refname: &str) -> Option<String> {
1662 for spec in refspecs {
1663 let spec = spec.strip_prefix('+').unwrap_or(spec);
1664 let (src, dst) = spec.split_once(':').unwrap_or((spec, spec));
1665 if let Some(src_prefix) = src.strip_suffix('*') {
1666 if let (Some(suffix), Some(dst_prefix)) =
1667 (refname.strip_prefix(src_prefix), dst.strip_suffix('*'))
1668 {
1669 return Some(format!("{dst_prefix}{suffix}"));
1670 }
1671 } else if src == refname {
1672 return Some(dst.to_string());
1673 }
1674 }
1675 None
1676}
1677
1678fn read_repo_config(git_dir: &Path) -> Result<GitConfig> {
1687 sley_config::read_repo_config(git_dir, None)
1688}
1689
1690#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1691enum RevisionSuffix<'a> {
1692 Parent(usize),
1693 FirstParent(usize),
1694 Peel(PeelKind),
1695 Search(&'a str),
1697}
1698
1699#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1700enum PeelKind {
1701 AnyNonTag,
1702 Object,
1703 Commit,
1704 Tree,
1705 Tag,
1706 Blob,
1707}
1708
1709fn split_revision_suffix(rev: &str) -> Result<Option<(&str, RevisionSuffix<'_>)>> {
1710 let caret = rev.rfind('^');
1711 let tilde = rev.rfind('~');
1712 let Some((op, pos)) = (match (caret, tilde) {
1713 (Some(caret), Some(tilde)) if caret > tilde => Some(('^', caret)),
1714 (Some(caret), Some(tilde)) if tilde > caret => Some(('~', tilde)),
1715 (Some(caret), None) => Some(('^', caret)),
1716 (None, Some(tilde)) => Some(('~', tilde)),
1717 (None, None) => None,
1718 _ => None,
1719 }) else {
1720 return Ok(None);
1721 };
1722 let (base, suffix) = rev.split_at(pos);
1723 let suffix = &suffix[1..];
1724 match op {
1725 '^' => {
1726 if let Some(text) = parse_search_suffix(rev, suffix)? {
1727 return Ok(Some((base, RevisionSuffix::Search(text))));
1728 }
1729 let parent = if suffix.is_empty() {
1730 1
1731 } else if let Some(kind) = parse_peel_suffix(rev, suffix)? {
1732 return Ok(Some((base, RevisionSuffix::Peel(kind))));
1733 } else if suffix.bytes().all(|byte| byte.is_ascii_digit()) {
1734 parse_revision_count(rev, suffix)?
1735 } else {
1736 return Ok(None);
1737 };
1738 Ok(Some((base, RevisionSuffix::Parent(parent))))
1739 }
1740 '~' => {
1741 let count = if suffix.is_empty() {
1742 1
1743 } else if suffix.bytes().all(|byte| byte.is_ascii_digit()) {
1744 parse_revision_count(rev, suffix)?
1745 } else {
1746 return Ok(None);
1747 };
1748 Ok(Some((base, RevisionSuffix::FirstParent(count))))
1749 }
1750 _ => Ok(None),
1751 }
1752}
1753
1754fn parse_peel_suffix(rev: &str, suffix: &str) -> Result<Option<PeelKind>> {
1755 if !suffix.starts_with('{') {
1756 return Ok(None);
1757 }
1758 let Some(kind) = suffix
1759 .strip_prefix('{')
1760 .and_then(|value| value.strip_suffix('}'))
1761 else {
1762 return Err(GitError::InvalidFormat(format!(
1763 "invalid revision peel suffix in {rev}"
1764 )));
1765 };
1766 let kind = match kind {
1767 "" => PeelKind::AnyNonTag,
1768 "object" => PeelKind::Object,
1769 "commit" => PeelKind::Commit,
1770 "tree" => PeelKind::Tree,
1771 "tag" => PeelKind::Tag,
1772 "blob" => PeelKind::Blob,
1773 other => {
1774 return Err(GitError::Unsupported(format!(
1775 "revision peel suffix ^{{{other}}}"
1776 )));
1777 }
1778 };
1779 Ok(Some(kind))
1780}
1781
1782fn parse_search_suffix<'a>(rev: &str, suffix: &'a str) -> Result<Option<&'a str>> {
1783 let Some(inner) = suffix.strip_prefix("{/") else {
1784 return Ok(None);
1785 };
1786 let Some(text) = inner.strip_suffix('}') else {
1787 return Err(GitError::InvalidFormat(format!(
1788 "invalid revision search suffix in {rev}"
1789 )));
1790 };
1791 Ok(Some(text))
1792}
1793
1794fn parse_revision_count(rev: &str, text: &str) -> Result<usize> {
1795 text.parse::<usize>()
1796 .map_err(|_| GitError::InvalidFormat(format!("invalid revision suffix in {rev}")))
1797}
1798
1799fn peel_base_to_commit_if_needed<R: ObjectReader>(
1805 reader: &R,
1806 format: sley_core::ObjectFormat,
1807 graph: &mut CommitGraphContext<'_>,
1808 base: &ObjectId,
1809) -> Result<ObjectId> {
1810 if graph.lookup(base)?.is_some() {
1811 return Ok(*base);
1812 }
1813 peel_to_commit(reader, format, base)
1814}
1815
1816fn apply_revision_suffix<R: ObjectReader>(
1817 git_dir: &Path,
1818 reader: &R,
1819 format: sley_core::ObjectFormat,
1820 base: &ObjectId,
1821 suffix: RevisionSuffix<'_>,
1822 raw_rev: &str,
1823) -> Result<ObjectId> {
1824 match suffix {
1825 RevisionSuffix::Parent(parent) => {
1826 if parent == 0 {
1827 let _ = raw_rev;
1832 return peel_revision(reader, format, base, PeelKind::Commit);
1833 }
1834 let mut graph = CommitGraphContext::load(git_dir, format);
1839 let grafts = revlist::load_commit_grafts_from_git_dir(git_dir, format);
1840 let base = peel_base_to_commit_if_needed(reader, format, &mut graph, base)?;
1841 revision_suffix_commit_parents(&mut graph, reader, format, &base, &grafts)?
1842 .get(parent - 1)
1843 .cloned()
1844 .ok_or_else(|| GitError::not_found(format!("parent {parent} of {base}")))
1845 }
1846 RevisionSuffix::FirstParent(count) => {
1847 let mut graph = CommitGraphContext::load(git_dir, format);
1850 let grafts = revlist::load_commit_grafts_from_git_dir(git_dir, format);
1851 let mut current = peel_base_to_commit_if_needed(reader, format, &mut graph, base)?;
1852 for _ in 0..count {
1853 current =
1854 revision_suffix_commit_parents(&mut graph, reader, format, ¤t, &grafts)?
1855 .into_iter()
1856 .next()
1857 .ok_or_else(|| GitError::not_found(format!("first parent of {current}")))?;
1858 }
1859 Ok(current)
1860 }
1861 RevisionSuffix::Peel(kind) => peel_revision(reader, format, base, kind),
1862 RevisionSuffix::Search(text) => {
1863 search_commit_message_first_parent(git_dir, reader, format, base, text)
1864 }
1865 }
1866}
1867
1868fn revision_suffix_commit_parents<R: ObjectReader>(
1869 graph: &mut CommitGraphContext,
1870 reader: &R,
1871 format: sley_core::ObjectFormat,
1872 oid: &ObjectId,
1873 grafts: &HashMap<ObjectId, Vec<ObjectId>>,
1874) -> Result<Vec<ObjectId>> {
1875 if let Some(parents) = grafts.get(oid) {
1876 return Ok(sley_odb::grafted_parents(reader, oid, parents.clone()));
1877 }
1878 if grafts.is_empty() {
1879 return graph.commit_parents(reader, oid);
1880 }
1881 commit_parents(reader, format, oid)
1882}
1883
1884const GENERATION_NUMBER_ZERO: u32 = 0;
1909
1910#[derive(Debug, Clone)]
1916enum GraphParents {
1917 None,
1918 One(ObjectId),
1919 Two([ObjectId; 2]),
1920 Many(Vec<ObjectId>),
1921}
1922
1923impl GraphParents {
1924 fn from_oids<I>(parents: I) -> Self
1925 where
1926 I: IntoIterator<Item = ObjectId>,
1927 {
1928 let mut parents = parents.into_iter();
1929 let Some(first) = parents.next() else {
1930 return Self::None;
1931 };
1932 let Some(second) = parents.next() else {
1933 return Self::One(first);
1934 };
1935 let Some(third) = parents.next() else {
1936 return Self::Two([first, second]);
1937 };
1938 let (lower, _) = parents.size_hint();
1939 let mut many = Vec::with_capacity(3 + lower);
1940 many.push(first);
1941 many.push(second);
1942 many.push(third);
1943 many.extend(parents);
1944 Self::Many(many)
1945 }
1946
1947 fn is_empty(&self) -> bool {
1948 matches!(self, Self::None)
1949 }
1950
1951 fn first(&self) -> Option<ObjectId> {
1952 match self {
1953 Self::None => None,
1954 Self::One(parent) => Some(*parent),
1955 Self::Two(parents) => Some(parents[0]),
1956 Self::Many(parents) => parents.first().copied(),
1957 }
1958 }
1959
1960 fn iter(&self) -> GraphParentIter<'_> {
1961 match self {
1962 Self::None => GraphParentIter::Empty,
1963 Self::One(parent) => GraphParentIter::One(Some(*parent)),
1964 Self::Two(parents) => GraphParentIter::Slice(parents.iter().copied()),
1965 Self::Many(parents) => GraphParentIter::Slice(parents.iter().copied()),
1966 }
1967 }
1968
1969 fn to_vec(&self) -> Vec<ObjectId> {
1970 match self {
1971 Self::None => Vec::new(),
1972 Self::One(parent) => vec![*parent],
1973 Self::Two(parents) => parents.to_vec(),
1974 Self::Many(parents) => parents.clone(),
1975 }
1976 }
1977
1978 fn grafted_vec<R: ObjectReader>(&self, reader: &R, oid: &ObjectId) -> Vec<ObjectId> {
1979 if reader.is_shallow_graft(oid) {
1980 Vec::new()
1981 } else {
1982 self.to_vec()
1983 }
1984 }
1985}
1986
1987enum GraphParentIter<'a> {
1988 Empty,
1989 One(Option<ObjectId>),
1990 Slice(std::iter::Copied<std::slice::Iter<'a, ObjectId>>),
1991}
1992
1993impl Iterator for GraphParentIter<'_> {
1994 type Item = ObjectId;
1995
1996 fn next(&mut self) -> Option<Self::Item> {
1997 match self {
1998 Self::Empty => None,
1999 Self::One(parent) => parent.take(),
2000 Self::Slice(parents) => parents.next(),
2001 }
2002 }
2003
2004 fn size_hint(&self) -> (usize, Option<usize>) {
2005 match self {
2006 Self::Empty => (0, Some(0)),
2007 Self::One(Some(_)) => (1, Some(1)),
2008 Self::One(None) => (0, Some(0)),
2009 Self::Slice(parents) => parents.size_hint(),
2010 }
2011 }
2012}
2013
2014impl ExactSizeIterator for GraphParentIter<'_> {}
2015
2016enum CommitParentIds<'a> {
2017 Empty,
2018 Borrowed(GraphParentIter<'a>),
2019 Owned(std::vec::IntoIter<ObjectId>),
2020}
2021
2022impl<'a> CommitParentIds<'a> {
2023 fn borrowed(parents: &'a GraphParents) -> Self {
2024 Self::Borrowed(parents.iter())
2025 }
2026
2027 fn owned(parents: Vec<ObjectId>) -> Self {
2028 Self::Owned(parents.into_iter())
2029 }
2030}
2031
2032impl Iterator for CommitParentIds<'_> {
2033 type Item = ObjectId;
2034
2035 fn next(&mut self) -> Option<Self::Item> {
2036 match self {
2037 Self::Empty => None,
2038 Self::Borrowed(parents) => parents.next(),
2039 Self::Owned(parents) => parents.next(),
2040 }
2041 }
2042}
2043
2044#[derive(Debug, Clone)]
2047struct GraphCommit {
2048 parents: GraphParents,
2049 generation: u32,
2050 commit_time: u64,
2051}
2052
2053struct GraphCommitMetadata<'a> {
2054 parents: &'a GraphParents,
2055 commit_time: i64,
2056}
2057
2058#[derive(Debug, Clone)]
2059struct GraphBloomCommit {
2060 parents: GraphParents,
2061 filter: Option<Vec<u8>>,
2062 settings: sley_formats::CommitGraphBloomSettings,
2063}
2064
2065#[derive(Debug, Clone, Copy, Default)]
2066struct GraphBloomStats {
2067 filter_not_present: usize,
2068 maybe: usize,
2069 definitely_not: usize,
2070 false_positive: usize,
2071}
2072
2073#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2074enum GraphBloomConsult {
2075 DefinitelyNot,
2076 Maybe,
2077 NotPresent,
2078 NotInGraph,
2079}
2080
2081struct CommitGraphContext<'a> {
2091 git_dir: &'a Path,
2092 format: sley_core::ObjectFormat,
2093 direct_graph: Option<DirectCommitGraph>,
2097 commits: Option<std::result::Result<HashMap<ObjectId, GraphCommit>, String>>,
2101}
2102
2103enum DirectCommitGraph {
2104 Missing,
2105 Invalid(String),
2106 Raw(Box<RawCommitGraph>),
2107}
2108
2109struct RawCommitGraph {
2110 bytes: RawCommitGraphBytes,
2111 format: ObjectFormat,
2112 fanout: [u32; 256],
2113 commit_count: usize,
2114 entry_len: usize,
2115 oidl: Range<usize>,
2116 cdat: Range<usize>,
2117 edge: Option<Range<usize>>,
2118}
2119
2120struct RawCommitGraphCountState {
2121 seen: Vec<u64>,
2122 pending: Vec<usize>,
2123}
2124
2125impl RawCommitGraphCountState {
2126 fn new(commit_count: usize) -> Self {
2127 Self {
2128 seen: vec![0u64; commit_count.div_ceil(64)],
2129 pending: Vec::new(),
2130 }
2131 }
2132}
2133
2134enum RawCommitGraphBytes {
2135 Owned(Vec<u8>),
2136 Mapped(sley_mmap::MappedFile),
2137}
2138
2139impl AsRef<[u8]> for RawCommitGraphBytes {
2140 fn as_ref(&self) -> &[u8] {
2141 match self {
2142 Self::Owned(bytes) => bytes,
2143 Self::Mapped(bytes) => bytes.as_bytes(),
2144 }
2145 }
2146}
2147
2148impl RawCommitGraph {
2149 fn parse_for_lookup(bytes: RawCommitGraphBytes, format: ObjectFormat) -> Result<Self> {
2150 let data = bytes.as_ref();
2151 let hash_len = format.raw_len();
2152 if data.len() < 8 + 12 + hash_len {
2153 return Err(GitError::InvalidFormat(
2154 "commit-graph file too short".into(),
2155 ));
2156 }
2157 if &data[..4] != b"CGPH" {
2158 return Err(GitError::InvalidFormat(
2159 "missing commit-graph signature".into(),
2160 ));
2161 }
2162 let version = data[4];
2163 if version != 1 {
2164 return Err(GitError::Unsupported(format!(
2165 "commit-graph version {version}"
2166 )));
2167 }
2168 let hash_id = data[5];
2169 if u32::from(hash_id) != commit_graph_hash_function_id(format) {
2170 return Err(GitError::InvalidFormat(format!(
2171 "commit-graph hash id {hash_id} does not match {}",
2172 format.name()
2173 )));
2174 }
2175 if data[7] != 0 {
2176 return Err(GitError::Unsupported(
2177 "split commit-graph direct lookup".into(),
2178 ));
2179 }
2180 let chunk_count = data[6] as usize;
2181 let lookup_len = (chunk_count + 1)
2182 .checked_mul(12)
2183 .ok_or_else(|| GitError::InvalidFormat("commit-graph lookup overflow".into()))?;
2184 let data_start = 8usize
2185 .checked_add(lookup_len)
2186 .ok_or_else(|| GitError::InvalidFormat("commit-graph lookup overflow".into()))?;
2187 let checksum_offset = data.len() - hash_len;
2188 if data_start > checksum_offset {
2189 return Err(GitError::InvalidFormat(
2190 "truncated commit-graph chunk lookup".into(),
2191 ));
2192 }
2193
2194 let mut lookup = Vec::with_capacity(chunk_count + 1);
2195 let mut offset = 8usize;
2196 for _ in 0..=chunk_count {
2197 let id = [
2198 data[offset],
2199 data[offset + 1],
2200 data[offset + 2],
2201 data[offset + 3],
2202 ];
2203 let chunk_offset = read_u64_be(&data[offset + 4..offset + 12]);
2204 lookup.push((id, chunk_offset));
2205 offset += 12;
2206 }
2207 let Some((terminator_id, terminator_offset)) = lookup.last().copied() else {
2208 return Err(GitError::InvalidFormat(
2209 "commit-graph chunk lookup is empty".into(),
2210 ));
2211 };
2212 if terminator_id != [0, 0, 0, 0] {
2213 return Err(GitError::InvalidFormat(
2214 "commit-graph chunk lookup missing terminator".into(),
2215 ));
2216 }
2217 if terminator_offset != checksum_offset as u64 {
2218 return Err(GitError::InvalidFormat(
2219 "commit-graph terminator does not point at checksum".into(),
2220 ));
2221 }
2222
2223 let mut chunks = Vec::with_capacity(chunk_count);
2224 let mut previous_offset = data_start;
2225 for pair in lookup.windows(2) {
2226 let (id, chunk_offset) = pair[0];
2227 let (_next_id, next_offset) = pair[1];
2228 if id == [0, 0, 0, 0] {
2229 return Err(GitError::InvalidFormat(
2230 "commit-graph chunk id is zero before terminator".into(),
2231 ));
2232 }
2233 if chunks
2234 .iter()
2235 .any(|(seen, _): &([u8; 4], Range<usize>)| *seen == id)
2236 {
2237 return Err(GitError::InvalidFormat(
2238 "commit-graph chunk id is duplicated".into(),
2239 ));
2240 }
2241 let start = usize::try_from(chunk_offset).map_err(|_| {
2242 GitError::InvalidFormat("commit-graph chunk offset overflow".into())
2243 })?;
2244 let end = usize::try_from(next_offset).map_err(|_| {
2245 GitError::InvalidFormat("commit-graph chunk offset overflow".into())
2246 })?;
2247 if start < data_start || start < previous_offset || end < start || end > checksum_offset
2248 {
2249 return Err(GitError::InvalidFormat(
2250 "commit-graph chunk length is invalid".into(),
2251 ));
2252 }
2253 chunks.push((id, start..end));
2254 previous_offset = start;
2255 }
2256
2257 let oidf = raw_commit_graph_chunk(&chunks, *b"OIDF")
2258 .ok_or_else(|| GitError::InvalidFormat("commit-graph missing OIDF chunk".into()))?;
2259 if oidf.len() != 256 * 4 {
2260 return Err(GitError::InvalidFormat(
2261 "commit-graph OIDF chunk has invalid length".into(),
2262 ));
2263 }
2264 let mut fanout = [0u32; 256];
2265 let mut previous = 0u32;
2266 for (idx, slot) in fanout.iter_mut().enumerate() {
2267 let start = oidf.start + idx * 4;
2268 *slot = read_u32_be(&data[start..start + 4]);
2269 if *slot < previous {
2270 return Err(GitError::InvalidFormat(
2271 "commit-graph OIDF fanout is not monotonic".into(),
2272 ));
2273 }
2274 previous = *slot;
2275 }
2276 let commit_count = fanout[255] as usize;
2277 let oidl = raw_commit_graph_chunk(&chunks, *b"OIDL")
2278 .ok_or_else(|| GitError::InvalidFormat("commit-graph missing OIDL chunk".into()))?;
2279 let expected_oidl_len = commit_count
2280 .checked_mul(hash_len)
2281 .ok_or_else(|| GitError::InvalidFormat("commit-graph OIDL chunk overflow".into()))?;
2282 if oidl.len() != expected_oidl_len {
2283 return Err(GitError::InvalidFormat(
2284 "commit-graph OIDL chunk has invalid length".into(),
2285 ));
2286 }
2287 let cdat = raw_commit_graph_chunk(&chunks, *b"CDAT")
2288 .ok_or_else(|| GitError::InvalidFormat("commit-graph missing CDAT chunk".into()))?;
2289 let entry_len = raw_commit_graph_entry_len(format)?;
2290 let expected_cdat_len = commit_count
2291 .checked_mul(entry_len)
2292 .ok_or_else(|| GitError::InvalidFormat("commit-graph CDAT chunk overflow".into()))?;
2293 if cdat.len() != expected_cdat_len {
2294 return Err(GitError::InvalidFormat(
2295 "commit-graph CDAT chunk has invalid length".into(),
2296 ));
2297 }
2298 let edge = raw_commit_graph_chunk(&chunks, *b"EDGE");
2299 if let Some(edge) = &edge
2300 && edge.len() % 4 != 0
2301 {
2302 return Err(GitError::InvalidFormat(
2303 "commit-graph EDGE chunk has invalid length".into(),
2304 ));
2305 }
2306 raw_commit_graph_validate_generation_data(data, &chunks, commit_count)?;
2307
2308 Ok(Self {
2309 bytes,
2310 format,
2311 fanout,
2312 commit_count,
2313 entry_len,
2314 oidl,
2315 cdat,
2316 edge,
2317 })
2318 }
2319
2320 fn metadata(&self, oid: &ObjectId) -> Result<Option<CommitMetadata>> {
2321 if oid.format() != self.format {
2322 return Ok(None);
2323 }
2324 let Some(idx) = self.find_index(oid)? else {
2325 return Ok(None);
2326 };
2327 let entry = self.cdat_entry(idx)?;
2328 let hash_len = self.format.raw_len();
2329 let parent_one = read_u32_be(&entry[hash_len..hash_len + 4]);
2330 let parent_two = read_u32_be(&entry[hash_len + 4..hash_len + 8]);
2331 let generation_and_time_high = read_u32_be(&entry[hash_len + 8..hash_len + 12]);
2332 let time_low = read_u32_be(&entry[hash_len + 12..hash_len + 16]);
2333 let commit_time = (u64::from(generation_and_time_high & 0x3) << 32) | u64::from(time_low);
2334 Ok(Some(CommitMetadata {
2335 oid: *oid,
2336 parents: self.parent_oids(parent_one, parent_two)?,
2337 commit_time: i64::try_from(commit_time).unwrap_or(i64::MAX),
2338 }))
2339 }
2340
2341 fn tree_oid(&self, oid: &ObjectId) -> Result<Option<ObjectId>> {
2342 if oid.format() != self.format {
2343 return Ok(None);
2344 }
2345 let Some(idx) = self.find_index(oid)? else {
2346 return Ok(None);
2347 };
2348 let entry = self.cdat_entry(idx)?;
2349 let hash_len = self.format.raw_len();
2350 ObjectId::from_raw(self.format, &entry[..hash_len]).map(Some)
2351 }
2352
2353 fn count_reachable_indices(
2354 &self,
2355 starts: &[usize],
2356 first_parent: bool,
2357 state: &mut RawCommitGraphCountState,
2358 ) -> Result<usize> {
2359 state.pending.extend(starts.iter().copied());
2360 let mut count = 0usize;
2361 while let Some(idx) = state.pending.pop() {
2362 if idx >= self.commit_count {
2363 return Err(GitError::InvalidFormat(
2364 "commit-graph traversal index points past table".into(),
2365 ));
2366 }
2367 let word = idx / 64;
2368 let bit = 1u64 << (idx % 64);
2369 if state.seen[word] & bit != 0 {
2370 continue;
2371 }
2372 state.seen[word] |= bit;
2373 count += 1;
2374 self.push_parent_indices_for_entry(idx, first_parent, &mut state.pending)?;
2375 }
2376 Ok(count)
2377 }
2378
2379 fn find_index(&self, oid: &ObjectId) -> Result<Option<usize>> {
2380 let first = oid.as_bytes()[0] as usize;
2381 let mut low = if first == 0 {
2382 0
2383 } else {
2384 self.fanout[first - 1] as usize
2385 };
2386 let mut high = self.fanout[first] as usize;
2387 let needle = oid.as_bytes();
2388 while low < high {
2389 let mid = low + (high - low) / 2;
2390 match self.oid_bytes(mid)?.cmp(needle) {
2391 std::cmp::Ordering::Less => low = mid + 1,
2392 std::cmp::Ordering::Greater => high = mid,
2393 std::cmp::Ordering::Equal => return Ok(Some(mid)),
2394 }
2395 }
2396 Ok(None)
2397 }
2398
2399 fn oid_bytes(&self, idx: usize) -> Result<&[u8]> {
2400 if idx >= self.commit_count {
2401 return Err(GitError::InvalidFormat(
2402 "commit-graph oid index points past table".into(),
2403 ));
2404 }
2405 let hash_len = self.format.raw_len();
2406 let start = self
2407 .oidl
2408 .start
2409 .checked_add(idx.checked_mul(hash_len).ok_or_else(|| {
2410 GitError::InvalidFormat("commit-graph OIDL index overflow".into())
2411 })?)
2412 .ok_or_else(|| GitError::InvalidFormat("commit-graph OIDL index overflow".into()))?;
2413 let end = start
2414 .checked_add(hash_len)
2415 .ok_or_else(|| GitError::InvalidFormat("commit-graph OIDL index overflow".into()))?;
2416 self.bytes
2417 .as_ref()
2418 .get(start..end)
2419 .ok_or_else(|| GitError::InvalidFormat("commit-graph OIDL index overflow".into()))
2420 }
2421
2422 fn oid_at(&self, idx: u32) -> Result<ObjectId> {
2423 let idx = usize::try_from(idx)
2424 .map_err(|_| GitError::InvalidFormat("commit-graph parent index overflow".into()))?;
2425 ObjectId::from_raw(self.format, self.oid_bytes(idx)?)
2426 }
2427
2428 fn cdat_entry(&self, idx: usize) -> Result<&[u8]> {
2429 if idx >= self.commit_count {
2430 return Err(GitError::InvalidFormat(
2431 "commit-graph CDAT index points past table".into(),
2432 ));
2433 }
2434 let start = self.cdat.start + idx * self.entry_len;
2435 let end = start + self.entry_len;
2436 self.bytes
2437 .as_ref()
2438 .get(start..end)
2439 .ok_or_else(|| GitError::InvalidFormat("commit-graph CDAT index overflow".into()))
2440 }
2441
2442 fn push_parent_indices_for_entry(
2443 &self,
2444 idx: usize,
2445 first_parent: bool,
2446 out: &mut Vec<usize>,
2447 ) -> Result<()> {
2448 let entry = self.cdat_entry(idx)?;
2449 let hash_len = self.format.raw_len();
2450 let parent_one = read_u32_be(&entry[hash_len..hash_len + 4]);
2451 let parent_two = read_u32_be(&entry[hash_len + 4..hash_len + 8]);
2452 if parent_one != RAW_COMMIT_GRAPH_PARENT_NONE {
2453 validate_raw_commit_graph_parent(parent_one, self.commit_count)?;
2454 out.push(parent_one as usize);
2455 }
2456 if first_parent || parent_two == RAW_COMMIT_GRAPH_PARENT_NONE {
2457 return Ok(());
2458 }
2459 if parent_two & RAW_COMMIT_GRAPH_EXTRA_EDGE == 0 {
2460 validate_raw_commit_graph_parent(parent_two, self.commit_count)?;
2461 out.push(parent_two as usize);
2462 return Ok(());
2463 }
2464
2465 let Some(edge) = &self.edge else {
2466 return Err(GitError::InvalidFormat(
2467 "commit-graph octopus edge missing EDGE chunk".into(),
2468 ));
2469 };
2470 let mut edge_idx = (parent_two & RAW_COMMIT_GRAPH_EXTRA_EDGE_MASK) as usize;
2471 loop {
2472 let start = edge
2473 .start
2474 .checked_add(edge_idx.checked_mul(4).ok_or_else(|| {
2475 GitError::InvalidFormat("commit-graph EDGE index overflow".into())
2476 })?)
2477 .ok_or_else(|| {
2478 GitError::InvalidFormat("commit-graph EDGE index overflow".into())
2479 })?;
2480 let end = start.checked_add(4).ok_or_else(|| {
2481 GitError::InvalidFormat("commit-graph EDGE index overflow".into())
2482 })?;
2483 let Some(bytes) = self.bytes.as_ref().get(start..end) else {
2484 return Err(GitError::InvalidFormat(
2485 "commit-graph EDGE entry points past chunk".into(),
2486 ));
2487 };
2488 let raw = read_u32_be(bytes);
2489 let parent = raw & RAW_COMMIT_GRAPH_EXTRA_EDGE_MASK;
2490 validate_raw_commit_graph_parent(parent, self.commit_count)?;
2491 out.push(parent as usize);
2492 if raw & RAW_COMMIT_GRAPH_EXTRA_EDGE != 0 {
2493 return Ok(());
2494 }
2495 edge_idx = edge_idx.checked_add(1).ok_or_else(|| {
2496 GitError::InvalidFormat("commit-graph EDGE index overflow".into())
2497 })?;
2498 }
2499 }
2500
2501 fn parent_oids(&self, parent_one: u32, parent_two: u32) -> Result<Vec<ObjectId>> {
2502 let mut parents = Vec::new();
2503 if parent_one != RAW_COMMIT_GRAPH_PARENT_NONE {
2504 validate_raw_commit_graph_parent(parent_one, self.commit_count)?;
2505 parents.push(self.oid_at(parent_one)?);
2506 }
2507 if parent_two == RAW_COMMIT_GRAPH_PARENT_NONE {
2508 return Ok(parents);
2509 }
2510 if parent_two & RAW_COMMIT_GRAPH_EXTRA_EDGE == 0 {
2511 validate_raw_commit_graph_parent(parent_two, self.commit_count)?;
2512 parents.push(self.oid_at(parent_two)?);
2513 return Ok(parents);
2514 }
2515
2516 let Some(edge) = &self.edge else {
2517 return Err(GitError::InvalidFormat(
2518 "commit-graph octopus edge missing EDGE chunk".into(),
2519 ));
2520 };
2521 let mut edge_idx = (parent_two & RAW_COMMIT_GRAPH_EXTRA_EDGE_MASK) as usize;
2522 loop {
2523 let start = edge
2524 .start
2525 .checked_add(edge_idx.checked_mul(4).ok_or_else(|| {
2526 GitError::InvalidFormat("commit-graph EDGE index overflow".into())
2527 })?)
2528 .ok_or_else(|| {
2529 GitError::InvalidFormat("commit-graph EDGE index overflow".into())
2530 })?;
2531 let end = start.checked_add(4).ok_or_else(|| {
2532 GitError::InvalidFormat("commit-graph EDGE index overflow".into())
2533 })?;
2534 let Some(bytes) = self.bytes.as_ref().get(start..end) else {
2535 return Err(GitError::InvalidFormat(
2536 "commit-graph EDGE entry points past chunk".into(),
2537 ));
2538 };
2539 let raw = read_u32_be(bytes);
2540 let parent = raw & RAW_COMMIT_GRAPH_EXTRA_EDGE_MASK;
2541 validate_raw_commit_graph_parent(parent, self.commit_count)?;
2542 parents.push(self.oid_at(parent)?);
2543 if raw & RAW_COMMIT_GRAPH_EXTRA_EDGE != 0 {
2544 return Ok(parents);
2545 }
2546 edge_idx = edge_idx.checked_add(1).ok_or_else(|| {
2547 GitError::InvalidFormat("commit-graph EDGE index overflow".into())
2548 })?;
2549 }
2550 }
2551}
2552
2553impl<'a> CommitGraphContext<'a> {
2554 fn load(git_dir: &'a Path, format: sley_core::ObjectFormat) -> Self {
2555 Self {
2556 git_dir,
2557 format,
2558 direct_graph: None,
2559 commits: None,
2560 }
2561 }
2562
2563 fn direct_graph(&mut self) -> &DirectCommitGraph {
2564 if self.direct_graph.is_none() {
2565 self.direct_graph = Some(load_direct_commit_graph(self.git_dir, self.format));
2566 }
2567 self.direct_graph
2568 .as_ref()
2569 .expect("direct commit graph load state initialized")
2570 }
2571
2572 fn count_reachable_direct(
2573 &mut self,
2574 starts: &[ObjectId],
2575 first_parent: bool,
2576 ) -> Result<Option<usize>> {
2577 let format = self.format;
2578 let DirectCommitGraph::Raw(graph) = self.direct_graph() else {
2579 return Ok(None);
2580 };
2581 let mut indices = Vec::with_capacity(starts.len());
2582 for oid in starts {
2583 if oid.format() != format {
2584 return Ok(None);
2585 }
2586 let Some(idx) = graph.find_index(oid)? else {
2587 return Ok(None);
2588 };
2589 indices.push(idx);
2590 }
2591 let mut state = RawCommitGraphCountState::new(graph.commit_count);
2592 graph
2593 .count_reachable_indices(&indices, first_parent, &mut state)
2594 .map(Some)
2595 }
2596
2597 fn count_reachable_graph_oid(
2598 &mut self,
2599 oid: &ObjectId,
2600 first_parent: bool,
2601 state: &mut Option<RawCommitGraphCountState>,
2602 ) -> Result<Option<usize>> {
2603 let format = self.format;
2604 let DirectCommitGraph::Raw(graph) = self.direct_graph() else {
2605 return Ok(None);
2606 };
2607 if oid.format() != format {
2608 return Ok(None);
2609 }
2610 let Some(idx) = graph.find_index(oid)? else {
2611 return Ok(None);
2612 };
2613 let state = state.get_or_insert_with(|| RawCommitGraphCountState::new(graph.commit_count));
2614 graph
2615 .count_reachable_indices(&[idx], first_parent, state)
2616 .map(Some)
2617 }
2618
2619 fn lookup(&mut self, oid: &ObjectId) -> Result<Option<&GraphCommit>> {
2622 if self.commits.is_none() {
2623 self.commits = Some(
2624 load_commit_graph_map(self.git_dir, self.format).map_err(|err| err.to_string()),
2625 );
2626 }
2627 match self
2628 .commits
2629 .as_ref()
2630 .expect("commit graph map load state initialized")
2631 {
2632 Ok(map) => Ok(map.get(oid)),
2633 Err(message) => Err(GitError::InvalidFormat(message.clone())),
2634 }
2635 }
2636
2637 fn parents(&mut self, oid: &ObjectId) -> Result<Option<&GraphParents>> {
2639 Ok(self.lookup(oid)?.map(|commit| &commit.parents))
2640 }
2641
2642 fn first_parent(&mut self, oid: &ObjectId) -> Result<Option<Option<ObjectId>>> {
2646 Ok(self.lookup(oid)?.map(|commit| commit.parents.first()))
2647 }
2648
2649 fn generation(&mut self, oid: &ObjectId) -> Result<Option<u32>> {
2653 Ok(match self.lookup(oid)? {
2654 Some(commit) if commit.generation != GENERATION_NUMBER_ZERO => Some(commit.generation),
2655 _ => None,
2656 })
2657 }
2658
2659 fn commit_time(&mut self, oid: &ObjectId) -> Result<Option<i64>> {
2663 Ok(self
2664 .lookup(oid)?
2665 .map(|commit| i64::try_from(commit.commit_time).unwrap_or(i64::MAX)))
2666 }
2667
2668 fn commit_parents<R: ObjectReader>(
2671 &mut self,
2672 reader: &R,
2673 oid: &ObjectId,
2674 ) -> Result<Vec<ObjectId>> {
2675 if reader.is_shallow_graft(oid) {
2678 return Ok(Vec::new());
2679 }
2680 let format = self.format;
2681 if let Some(parents) = self.parents(oid)? {
2682 return Ok(parents.to_vec());
2683 }
2684 commit_parents(reader, format, oid)
2685 }
2686
2687 fn commit_parent_ids<R: ObjectReader>(
2691 &mut self,
2692 reader: &R,
2693 oid: &ObjectId,
2694 ) -> Result<CommitParentIds<'_>> {
2695 if reader.is_shallow_graft(oid) {
2696 return Ok(CommitParentIds::Empty);
2697 }
2698 let format = self.format;
2699 if let Some(parents) = self.parents(oid)? {
2700 return Ok(CommitParentIds::borrowed(parents));
2701 }
2702 Ok(CommitParentIds::owned(commit_parents(reader, format, oid)?))
2703 }
2704
2705 fn commit_first_parent<R: ObjectReader>(
2708 &mut self,
2709 reader: &R,
2710 oid: &ObjectId,
2711 ) -> Result<Option<ObjectId>> {
2712 if reader.is_shallow_graft(oid) {
2713 return Ok(None);
2714 }
2715 let format = self.format;
2716 if let Some(parent) = self.first_parent(oid)? {
2717 return Ok(parent);
2718 }
2719 Ok(commit_parents(reader, format, oid)?.into_iter().next())
2720 }
2721
2722 fn metadata(&mut self, oid: &ObjectId) -> Result<Option<GraphCommitMetadata<'_>>> {
2725 Ok(self.lookup(oid)?.map(|commit| GraphCommitMetadata {
2726 parents: &commit.parents,
2727 commit_time: i64::try_from(commit.commit_time).unwrap_or(i64::MAX),
2728 }))
2729 }
2730
2731 fn metadata_owned<R: ObjectReader>(
2732 &mut self,
2733 reader: &R,
2734 oid: &ObjectId,
2735 ) -> Result<Option<CommitMetadata>> {
2736 match self.direct_graph() {
2737 DirectCommitGraph::Raw(graph) => {
2738 let Some(mut metadata) = graph.metadata(oid).unwrap_or(None) else {
2739 return Ok(None);
2740 };
2741 if reader.is_shallow_graft(oid) {
2742 metadata.parents.clear();
2743 }
2744 return Ok(Some(metadata));
2745 }
2746 DirectCommitGraph::Invalid(_) => return Ok(None),
2747 DirectCommitGraph::Missing => {}
2748 }
2749 Ok(self.metadata(oid)?.map(|metadata| CommitMetadata {
2750 oid: *oid,
2751 parents: metadata.parents.grafted_vec(reader, oid),
2752 commit_time: metadata.commit_time,
2753 }))
2754 }
2755}
2756
2757fn load_commit_graph_map(
2772 git_dir: &Path,
2773 format: sley_core::ObjectFormat,
2774) -> Result<HashMap<ObjectId, GraphCommit>> {
2775 let info = repository_objects_dir(git_dir).join("info");
2776 let single = info.join("commit-graph");
2777 if single.exists() {
2778 let bytes = match fs::read(&single) {
2779 Ok(bytes) => bytes,
2780 Err(err) => return Err(GitError::Io(err.to_string())),
2781 };
2782 if commit_graph_hash_version_mismatch(&bytes, format) {
2783 return Err(GitError::InvalidFormat(
2784 "commit-graph hash version mismatch".into(),
2785 ));
2786 }
2787 return match CommitGraph::parse(&bytes, format) {
2788 Ok(graph) => graph_to_map(&graph),
2789 Err(_) => {
2790 warn_invalid_commit_graph_bloom_chunks(&bytes, &single, format);
2791 if RawCommitGraph::parse_for_lookup(RawCommitGraphBytes::Owned(bytes), format)
2792 .is_err()
2793 {
2794 return Ok(HashMap::new());
2795 }
2796 Ok(HashMap::new())
2797 }
2798 };
2799 }
2800
2801 let chain = info.join("commit-graphs").join("commit-graph-chain");
2802 load_commit_graph_chain(&info, &chain, format)
2803}
2804
2805fn load_direct_commit_graph(git_dir: &Path, format: sley_core::ObjectFormat) -> DirectCommitGraph {
2806 let path = repository_objects_dir(git_dir)
2807 .join("info")
2808 .join("commit-graph");
2809 if !path.exists() {
2810 return DirectCommitGraph::Missing;
2811 }
2812 let bytes = match sley_mmap::MappedFile::open_commit_graph(&path) {
2813 Ok(mapped) => RawCommitGraphBytes::Mapped(mapped),
2814 Err(_) => match fs::read(&path) {
2815 Ok(bytes) => RawCommitGraphBytes::Owned(bytes),
2816 Err(err) => return DirectCommitGraph::Invalid(err.to_string()),
2817 },
2818 };
2819 if commit_graph_hash_version_mismatch(bytes.as_ref(), format) {
2820 return DirectCommitGraph::Invalid("commit-graph hash version mismatch".into());
2821 }
2822 warn_invalid_commit_graph_bloom_chunks(bytes.as_ref(), &path, format);
2823 match RawCommitGraph::parse_for_lookup(bytes, format) {
2824 Ok(graph) => DirectCommitGraph::Raw(Box::new(graph)),
2825 Err(GitError::InvalidFormat(message)) => DirectCommitGraph::Invalid(message),
2826 Err(err) => DirectCommitGraph::Invalid(err.to_string()),
2827 }
2828}
2829
2830const RAW_COMMIT_GRAPH_PARENT_NONE: u32 = 0x7000_0000;
2831const RAW_COMMIT_GRAPH_EXTRA_EDGE: u32 = 0x8000_0000;
2832const RAW_COMMIT_GRAPH_EXTRA_EDGE_MASK: u32 = 0x7fff_ffff;
2833
2834fn raw_commit_graph_chunk(chunks: &[([u8; 4], Range<usize>)], id: [u8; 4]) -> Option<Range<usize>> {
2835 chunks
2836 .iter()
2837 .find_map(|(chunk_id, range)| (*chunk_id == id).then(|| range.clone()))
2838}
2839
2840fn raw_commit_graph_validate_generation_data(
2841 data: &[u8],
2842 chunks: &[([u8; 4], Range<usize>)],
2843 commit_count: usize,
2844) -> Result<()> {
2845 let Some(gda2) = raw_commit_graph_chunk(chunks, *b"GDA2") else {
2846 return Ok(());
2847 };
2848 let expected_gda2_len = commit_count
2849 .checked_mul(4)
2850 .ok_or_else(|| GitError::InvalidFormat("commit-graph generation data overflow".into()))?;
2851 if gda2.len() != expected_gda2_len {
2852 return Err(GitError::InvalidFormat(
2853 "commit-graph generation data is the wrong size".into(),
2854 ));
2855 }
2856 let gdo2 = raw_commit_graph_chunk(chunks, *b"GDO2");
2857 if let Some(gdo2) = &gdo2
2858 && gdo2.len() % 8 != 0
2859 {
2860 return Err(GitError::InvalidFormat(
2861 "commit-graph overflow generation data is corrupt".into(),
2862 ));
2863 }
2864 for offset in (gda2.start..gda2.end).step_by(4) {
2865 let raw = read_u32_be(&data[offset..offset + 4]);
2866 if raw & 0x8000_0000 == 0 {
2867 continue;
2868 }
2869 let Some(gdo2) = &gdo2 else {
2870 return Err(GitError::InvalidFormat(
2871 "commit-graph overflow generation data is missing".into(),
2872 ));
2873 };
2874 let overflow_idx = (raw & 0x7fff_ffff) as usize;
2875 let overflow_start = overflow_idx.checked_mul(8).ok_or_else(|| {
2876 GitError::InvalidFormat("commit-graph overflow generation index overflow".into())
2877 })?;
2878 let overflow_end = overflow_start.checked_add(8).ok_or_else(|| {
2879 GitError::InvalidFormat("commit-graph overflow generation index overflow".into())
2880 })?;
2881 if overflow_end > gdo2.len() {
2882 return Err(GitError::InvalidFormat(
2883 "commit-graph overflow generation data is too small".into(),
2884 ));
2885 }
2886 }
2887 Ok(())
2888}
2889
2890fn raw_commit_graph_entry_len(format: ObjectFormat) -> Result<usize> {
2891 format
2892 .raw_len()
2893 .checked_add(16)
2894 .ok_or_else(|| GitError::InvalidFormat("commit-graph CDAT entry overflow".into()))
2895}
2896
2897fn validate_raw_commit_graph_parent(parent: u32, commit_count: usize) -> Result<()> {
2898 if parent as usize >= commit_count {
2899 return Err(GitError::InvalidFormat(
2900 "commit-graph parent points past commit table".into(),
2901 ));
2902 }
2903 Ok(())
2904}
2905
2906fn commit_graph_hash_function_id(format: ObjectFormat) -> u32 {
2907 match format {
2908 ObjectFormat::Sha1 => 1,
2909 ObjectFormat::Sha256 => 2,
2910 }
2911}
2912
2913fn commit_graph_hash_version_mismatch(bytes: &[u8], format: ObjectFormat) -> bool {
2918 if bytes.len() <= 5 || &bytes[..4] != b"CGPH" {
2919 return false;
2920 }
2921 let file_version = u32::from(bytes[5]);
2922 let repo_version = commit_graph_hash_function_id(format);
2923 if file_version == repo_version {
2924 return false;
2925 }
2926 use std::sync::atomic::{AtomicBool, Ordering};
2927 static WARNED: AtomicBool = AtomicBool::new(false);
2928 if !WARNED.swap(true, Ordering::Relaxed) {
2929 eprintln!(
2930 "error: commit-graph hash version {file_version} does not match version {repo_version}"
2931 );
2932 }
2933 true
2934}
2935
2936fn read_u32_be(bytes: &[u8]) -> u32 {
2937 u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])
2938}
2939
2940fn read_u64_be(bytes: &[u8]) -> u64 {
2941 u64::from_be_bytes([
2942 bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
2943 ])
2944}
2945
2946fn load_commit_graph_chain(
2954 info: &Path,
2955 chain: &Path,
2956 format: sley_core::ObjectFormat,
2957) -> Result<HashMap<ObjectId, GraphCommit>> {
2958 let contents = match fs::read_to_string(chain) {
2959 Ok(contents) => contents,
2960 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
2961 return Ok(HashMap::new());
2962 }
2963 Err(err) => return Err(GitError::Io(err.to_string())),
2964 };
2965 let mut merged: HashMap<ObjectId, GraphCommit> = HashMap::new();
2966 for line in contents.lines() {
2967 let hash = line.trim();
2968 if hash.is_empty() {
2969 continue;
2970 }
2971 let layer = info
2972 .join("commit-graphs")
2973 .join(format!("graph-{hash}.graph"));
2974 let bytes = fs::read(&layer).map_err(|err| GitError::Io(err.to_string()))?;
2975 let graph = match CommitGraph::parse(&bytes, format) {
2976 Ok(graph) => graph,
2977 Err(err) => {
2978 warn_invalid_commit_graph_bloom_chunks(&bytes, &layer, format);
2979 return Err(err);
2980 }
2981 };
2982 for (oid, commit) in graph_to_map(&graph)? {
2983 merged.insert(oid, commit);
2984 }
2985 }
2986 Ok(merged)
2987}
2988
2989fn graph_to_map(graph: &CommitGraph) -> Result<HashMap<ObjectId, GraphCommit>> {
2992 let mut map = HashMap::with_capacity(graph.commits.len());
2993 for entry in &graph.commits {
2994 let parents = GraphParents::from_oids(graph.parent_oids(entry)?);
2995 map.insert(
2996 entry.oid,
2997 GraphCommit {
2998 parents,
2999 generation: entry.generation,
3000 commit_time: entry.commit_time,
3001 },
3002 );
3003 }
3004 Ok(map)
3005}
3006
3007fn load_commit_graph_bloom_map(
3008 objects_dir: &Path,
3009 format: sley_core::ObjectFormat,
3010 requested_version: i64,
3011) -> HashMap<ObjectId, GraphBloomCommit> {
3012 let info = objects_dir.join("info");
3013 let graph_path = info.join("commit-graph");
3014 if !graph_path.exists() {
3015 let chain = info.join("commit-graphs").join("commit-graph-chain");
3016 return load_commit_graph_bloom_chain(&info, &chain, format, requested_version)
3017 .unwrap_or_default();
3018 }
3019 let bytes = match fs::read(&graph_path) {
3020 Ok(bytes) => bytes,
3021 Err(_) => return HashMap::new(),
3022 };
3023 match CommitGraph::parse(&bytes, format) {
3024 Ok(graph) => graph_to_bloom_map(&graph, requested_version, &[]).unwrap_or_default(),
3025 Err(_) => {
3026 warn_invalid_commit_graph_bloom_chunks(&bytes, &graph_path, format);
3027 HashMap::new()
3028 }
3029 }
3030}
3031
3032fn load_commit_graph_bloom_chain(
3033 info: &Path,
3034 chain: &Path,
3035 format: sley_core::ObjectFormat,
3036 requested_version: i64,
3037) -> Result<HashMap<ObjectId, GraphBloomCommit>> {
3038 let contents = match fs::read_to_string(chain) {
3039 Ok(contents) => contents,
3040 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
3041 return Ok(HashMap::new());
3042 }
3043 Err(err) => return Err(GitError::Io(err.to_string())),
3044 };
3045 let mut layers = Vec::new();
3046 let chain_dir = info.join("commit-graphs");
3047 for line in contents.lines() {
3048 let hash = line.trim();
3049 if hash.is_empty() {
3050 continue;
3051 }
3052 let layer = chain_dir.join(format!("graph-{hash}.graph"));
3053 let bytes = fs::read(&layer).map_err(|err| GitError::Io(err.to_string()))?;
3054 let graph = match CommitGraph::parse(&bytes, format) {
3055 Ok(graph) => graph,
3056 Err(err) => {
3057 warn_invalid_commit_graph_bloom_chunks(&bytes, &layer, format);
3058 return Err(err);
3059 }
3060 };
3061 layers.push((hash.to_string(), graph));
3062 }
3063 let canonical_settings = layers
3064 .iter()
3065 .rev()
3066 .filter_map(|(_, graph)| graph.bloom_filters.as_ref())
3067 .map(commit_graph_bloom_settings_from_filters)
3068 .find(|settings| {
3069 requested_version <= 0 || i64::from(settings.hash_version) == requested_version
3070 });
3071 let mut merged = HashMap::new();
3072 let mut base_oids = Vec::new();
3073 for (hash, graph) in layers {
3074 let layer_settings = graph
3075 .bloom_filters
3076 .as_ref()
3077 .map(commit_graph_bloom_settings_from_filters);
3078 let layer_map = if let (Some(canonical), Some(settings)) =
3079 (canonical_settings, layer_settings)
3080 && !commit_graph_bloom_settings_match(settings, canonical)
3081 {
3082 eprintln!(
3083 "warning: disabling Bloom filters for commit-graph layer '{hash}' due to incompatible settings"
3084 );
3085 graph_to_bloom_map_without_filters(&graph, settings, &base_oids)?
3086 } else {
3087 graph_to_bloom_map(&graph, requested_version, &base_oids)?
3088 };
3089 for (oid, bloom) in layer_map {
3090 merged.insert(oid, bloom);
3091 }
3092 base_oids.extend(graph.commits.iter().map(|entry| entry.oid));
3093 }
3094 Ok(merged)
3095}
3096
3097#[derive(Clone, Copy)]
3098struct GraphChunkView {
3099 id: [u8; 4],
3100 start: usize,
3101 end: usize,
3102}
3103
3104fn warn_invalid_commit_graph_bloom_chunks(
3105 bytes: &[u8],
3106 path: &Path,
3107 format: sley_core::ObjectFormat,
3108) {
3109 let Some((chunks, checksum_offset)) = commit_graph_chunk_views(bytes, format) else {
3110 return;
3111 };
3112 let Some(bdat) = commit_graph_chunk_view_data(bytes, &chunks, *b"BDAT") else {
3113 return;
3114 };
3115 let Some(bidx) = commit_graph_chunk_view_data(bytes, &chunks, *b"BIDX") else {
3116 return;
3117 };
3118 if bdat.len() < 12 {
3119 emit_commit_graph_bloom_warning_once(
3120 path,
3121 format!(
3122 "warning: ignoring too-small changed-path chunk ({} < 12) in commit-graph file",
3123 bdat.len()
3124 ),
3125 );
3126 return;
3127 }
3128 let commit_count = commit_graph_view_commit_count(bytes, &chunks, checksum_offset);
3129 if let Some(commit_count) = commit_count
3130 && bidx.len() / 4 != commit_count
3131 {
3132 emit_commit_graph_bloom_warning_once(
3133 path,
3134 "warning: commit-graph changed-path index chunk is too small".to_string(),
3135 );
3136 return;
3137 }
3138 let payload_len = bdat.len() - 12;
3139 let display_path = commit_graph_warning_path(path);
3140 let mut previous = 0usize;
3141 for idx in 0..(bidx.len() / 4) {
3142 let start = idx * 4;
3143 let cumulative = u32::from_be_bytes([
3144 bidx[start],
3145 bidx[start + 1],
3146 bidx[start + 2],
3147 bidx[start + 3],
3148 ]) as usize;
3149 if cumulative > payload_len {
3150 emit_commit_graph_bloom_warning_once(
3151 path,
3152 format!(
3153 "warning: ignoring out-of-range offset ({}) for changed-path filter at pos {} of {} (chunk size: {})",
3154 cumulative,
3155 idx,
3156 display_path,
3157 bdat.len()
3158 ),
3159 );
3160 return;
3161 }
3162 if cumulative < previous {
3163 emit_commit_graph_bloom_warning_once(
3164 path,
3165 format!(
3166 "warning: ignoring decreasing changed-path index offsets ({} > {}) for positions {} and {} of {}",
3167 previous,
3168 cumulative,
3169 idx.saturating_sub(1),
3170 idx,
3171 display_path
3172 ),
3173 );
3174 return;
3175 }
3176 previous = cumulative;
3177 }
3178}
3179
3180fn emit_commit_graph_bloom_warning_once(path: &Path, message: String) {
3181 static WARNED: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
3182 let warned = WARNED.get_or_init(|| Mutex::new(HashSet::new()));
3183 if let Ok(mut warned) = warned.lock()
3184 && !warned.insert(path.to_path_buf())
3185 {
3186 return;
3187 }
3188 eprintln!("{message}");
3189}
3190
3191fn warn_invalid_commit_graph_bloom_for_objects_dir(
3192 objects_dir: &Path,
3193 format: sley_core::ObjectFormat,
3194) {
3195 let info = objects_dir.join("info");
3196 let single = info.join("commit-graph");
3197 if single.exists() {
3198 if let Ok(bytes) = fs::read(&single) {
3199 warn_invalid_commit_graph_bloom_chunks(&bytes, &single, format);
3200 }
3201 return;
3202 }
3203 let chain = info.join("commit-graphs").join("commit-graph-chain");
3204 let Ok(contents) = fs::read_to_string(&chain) else {
3205 return;
3206 };
3207 for line in contents.lines() {
3208 let hash = line.trim();
3209 if hash.is_empty() {
3210 continue;
3211 }
3212 let layer = info
3213 .join("commit-graphs")
3214 .join(format!("graph-{hash}.graph"));
3215 if let Ok(bytes) = fs::read(&layer) {
3216 warn_invalid_commit_graph_bloom_chunks(&bytes, &layer, format);
3217 }
3218 }
3219}
3220
3221fn commit_graph_chunk_views(
3222 bytes: &[u8],
3223 format: sley_core::ObjectFormat,
3224) -> Option<(Vec<GraphChunkView>, usize)> {
3225 let hash_len = format.raw_len();
3226 if bytes.len() < 8 + 12 + hash_len || &bytes[..4] != b"CGPH" {
3227 return None;
3228 }
3229 let chunk_count = bytes[6] as usize;
3230 let lookup_len = (chunk_count + 1).checked_mul(12)?;
3231 let data_start = 8usize.checked_add(lookup_len)?;
3232 let checksum_offset = bytes.len().checked_sub(hash_len)?;
3233 if data_start > checksum_offset {
3234 return None;
3235 }
3236 let mut lookup = Vec::with_capacity(chunk_count + 1);
3237 let mut offset = 8usize;
3238 for _ in 0..=chunk_count {
3239 let id = [
3240 bytes[offset],
3241 bytes[offset + 1],
3242 bytes[offset + 2],
3243 bytes[offset + 3],
3244 ];
3245 let chunk_offset = u64::from_be_bytes([
3246 bytes[offset + 4],
3247 bytes[offset + 5],
3248 bytes[offset + 6],
3249 bytes[offset + 7],
3250 bytes[offset + 8],
3251 bytes[offset + 9],
3252 bytes[offset + 10],
3253 bytes[offset + 11],
3254 ]) as usize;
3255 lookup.push((id, chunk_offset));
3256 offset += 12;
3257 }
3258 let mut chunks = Vec::with_capacity(chunk_count);
3259 for pair in lookup.windows(2) {
3260 let (id, start) = pair[0];
3261 let (_next, end) = pair[1];
3262 if start > end || end > checksum_offset {
3263 return None;
3264 }
3265 chunks.push(GraphChunkView { id, start, end });
3266 }
3267 Some((chunks, checksum_offset))
3268}
3269
3270fn commit_graph_chunk_view_data<'a>(
3271 bytes: &'a [u8],
3272 chunks: &[GraphChunkView],
3273 id: [u8; 4],
3274) -> Option<&'a [u8]> {
3275 let chunk = chunks.iter().find(|chunk| chunk.id == id)?;
3276 bytes.get(chunk.start..chunk.end)
3277}
3278
3279fn commit_graph_view_commit_count(
3280 bytes: &[u8],
3281 chunks: &[GraphChunkView],
3282 _checksum_offset: usize,
3283) -> Option<usize> {
3284 let fanout = commit_graph_chunk_view_data(bytes, chunks, *b"OIDF")?;
3285 if fanout.len() != 256 * 4 {
3286 return None;
3287 }
3288 let last = fanout.len() - 4;
3289 Some(u32::from_be_bytes([
3290 fanout[last],
3291 fanout[last + 1],
3292 fanout[last + 2],
3293 fanout[last + 3],
3294 ]) as usize)
3295}
3296
3297fn commit_graph_warning_path(path: &Path) -> String {
3298 let text = path.to_string_lossy();
3299 if let Some(idx) = text.find(".git/objects/info/commit-graph") {
3300 return text[idx..].to_string();
3301 }
3302 text.into_owned()
3303}
3304
3305fn graph_to_bloom_map(
3306 graph: &CommitGraph,
3307 requested_version: i64,
3308 base_oids: &[ObjectId],
3309) -> Result<HashMap<ObjectId, GraphBloomCommit>> {
3310 let Some(filters) = &graph.bloom_filters else {
3311 return graph_to_bloom_map_without_filters(
3312 graph,
3313 sley_formats::DEFAULT_COMMIT_GRAPH_BLOOM_SETTINGS,
3314 base_oids,
3315 );
3316 };
3317 let settings = commit_graph_bloom_settings_from_filters(filters);
3318 if requested_version > 0 && i64::from(filters.hash_version) != requested_version {
3319 return graph_to_bloom_map_without_filters(graph, settings, base_oids);
3320 }
3321 let mut map = HashMap::with_capacity(graph.commits.len());
3322 for (idx, entry) in graph.commits.iter().enumerate() {
3323 let parents = commit_graph_entry_parent_oids_with_base(graph, entry, base_oids)?;
3324 let filter = filters
3325 .filter_for_commit(idx)
3326 .filter(|filter| !filter.is_empty())
3327 .map(|filter| filter.to_vec());
3328 map.insert(
3329 entry.oid,
3330 GraphBloomCommit {
3331 parents,
3332 filter,
3333 settings,
3334 },
3335 );
3336 }
3337 Ok(map)
3338}
3339
3340fn graph_to_bloom_map_without_filters(
3341 graph: &CommitGraph,
3342 settings: sley_formats::CommitGraphBloomSettings,
3343 base_oids: &[ObjectId],
3344) -> Result<HashMap<ObjectId, GraphBloomCommit>> {
3345 let mut map = HashMap::with_capacity(graph.commits.len());
3346 for entry in &graph.commits {
3347 let parents = commit_graph_entry_parent_oids_with_base(graph, entry, base_oids)?;
3348 map.insert(
3349 entry.oid,
3350 GraphBloomCommit {
3351 parents,
3352 filter: None,
3353 settings,
3354 },
3355 );
3356 }
3357 Ok(map)
3358}
3359
3360fn commit_graph_entry_parent_oids_with_base(
3361 graph: &CommitGraph,
3362 entry: &sley_formats::CommitGraphEntry,
3363 base_oids: &[ObjectId],
3364) -> Result<GraphParents> {
3365 let mut parents = Vec::with_capacity(entry.parents.len());
3366 for parent in entry.parent_indices() {
3367 let idx = usize::try_from(parent)
3368 .map_err(|_| GitError::InvalidFormat("commit-graph parent index overflow".into()))?;
3369 let oid = if idx < base_oids.len() {
3370 base_oids[idx]
3371 } else {
3372 let local = idx - base_oids.len();
3373 graph
3374 .commits
3375 .get(local)
3376 .map(|entry| entry.oid)
3377 .ok_or_else(|| {
3378 GitError::InvalidFormat("commit-graph parent points past commit table".into())
3379 })?
3380 };
3381 parents.push(oid);
3382 }
3383 Ok(GraphParents::from_oids(parents))
3384}
3385
3386fn commit_graph_bloom_settings_from_filters(
3387 filters: &sley_formats::CommitGraphBloomFilters,
3388) -> sley_formats::CommitGraphBloomSettings {
3389 let mut settings = sley_formats::DEFAULT_COMMIT_GRAPH_BLOOM_SETTINGS;
3390 settings.hash_version = filters.hash_version;
3391 settings.hash_count = filters.hash_count;
3392 settings.bits_per_entry = filters.bits_per_entry;
3393 settings
3394}
3395
3396fn commit_graph_bloom_settings_match(
3397 left: sley_formats::CommitGraphBloomSettings,
3398 right: sley_formats::CommitGraphBloomSettings,
3399) -> bool {
3400 left.hash_version == right.hash_version
3401 && left.hash_count == right.hash_count
3402 && left.bits_per_entry == right.bits_per_entry
3403}
3404
3405fn commit_parents<R: ObjectReader>(
3406 reader: &R,
3407 format: sley_core::ObjectFormat,
3408 oid: &ObjectId,
3409) -> Result<Vec<ObjectId>> {
3410 let object = read_revision_object(reader, oid)?;
3411 if object.object_type != ObjectType::Commit {
3412 return Err(GitError::InvalidObject(format!(
3413 "expected commit {oid}, found {}",
3414 object.object_type.as_str()
3415 )));
3416 }
3417 Ok(sley_odb::grafted_parents(
3418 reader,
3419 oid,
3420 Commit::parse_ref(format, &object.body)?.parents,
3421 ))
3422}
3423
3424fn peel_revision<R: ObjectReader>(
3425 reader: &R,
3426 format: sley_core::ObjectFormat,
3427 oid: &ObjectId,
3428 kind: PeelKind,
3429) -> Result<ObjectId> {
3430 match kind {
3431 PeelKind::AnyNonTag => peel_tags(reader, format, oid),
3432 PeelKind::Object => {
3433 read_revision_object(reader, oid)?;
3434 Ok(*oid)
3435 }
3436 PeelKind::Commit => peel_to_commit(reader, format, oid),
3437 PeelKind::Tree => peel_to_tree(reader, format, oid),
3438 PeelKind::Blob => peel_to_blob(reader, format, oid),
3439 PeelKind::Tag => {
3440 let object = read_revision_object(reader, oid)?;
3441 if object.object_type == ObjectType::Tag {
3442 Ok(*oid)
3443 } else {
3444 Err(GitError::InvalidObject(format!(
3445 "expected tag {oid}, found {}",
3446 object.object_type.as_str()
3447 )))
3448 }
3449 }
3450 }
3451}
3452
3453pub fn peel_tags<R: ObjectReader>(
3454 reader: &R,
3455 format: sley_core::ObjectFormat,
3456 oid: &ObjectId,
3457) -> Result<ObjectId> {
3458 let object = read_revision_object(reader, oid)?;
3459 if object.object_type != ObjectType::Tag {
3460 return Ok(*oid);
3461 }
3462 let tag = Tag::parse_ref(format, &object.body)?;
3463 peel_tags(reader, format, &tag.object)
3464}
3465
3466pub fn peel_to_tree<R: ObjectReader>(
3467 reader: &R,
3468 format: sley_core::ObjectFormat,
3469 oid: &ObjectId,
3470) -> Result<ObjectId> {
3471 let object = read_revision_object(reader, oid)?;
3472 match object.object_type {
3473 ObjectType::Tree => Ok(*oid),
3474 ObjectType::Commit => Ok(Commit::parse_ref(format, &object.body)?.tree),
3475 ObjectType::Tag => {
3476 let tag = Tag::parse_ref(format, &object.body)?;
3477 peel_to_tree(reader, format, &tag.object)
3478 }
3479 other => Err(GitError::InvalidObject(format!(
3480 "expected tree-ish {oid}, found {}",
3481 other.as_str()
3482 ))),
3483 }
3484}
3485
3486pub fn peel_to_commit<R: ObjectReader>(
3487 reader: &R,
3488 format: sley_core::ObjectFormat,
3489 oid: &ObjectId,
3490) -> Result<ObjectId> {
3491 let object = read_revision_object(reader, oid)?;
3492 match object.object_type {
3493 ObjectType::Commit => Ok(*oid),
3494 ObjectType::Tag => {
3495 let tag = Tag::parse_ref(format, &object.body)?;
3496 peel_to_commit(reader, format, &tag.object)
3497 }
3498 other => Err(GitError::InvalidObject(format!(
3499 "expected commit-ish {oid}, found {}",
3500 other.as_str()
3501 ))),
3502 }
3503}
3504
3505pub fn peel_to_blob<R: ObjectReader>(
3509 reader: &R,
3510 format: sley_core::ObjectFormat,
3511 oid: &ObjectId,
3512) -> Result<ObjectId> {
3513 let object = read_revision_object(reader, oid)?;
3514 match object.object_type {
3515 ObjectType::Blob => Ok(*oid),
3516 ObjectType::Tag => {
3517 let tag = Tag::parse_ref(format, &object.body)?;
3518 peel_to_blob(reader, format, &tag.object)
3519 }
3520 other => Err(GitError::InvalidObject(format!(
3521 "expected blob {oid}, found {}",
3522 other.as_str()
3523 ))),
3524 }
3525}
3526
3527pub fn pack_refs_with_auto_peel(
3528 git_dir: impl AsRef<Path>,
3529 format: sley_core::ObjectFormat,
3530 prune_loose: bool,
3531) -> Result<Vec<PackedRef>> {
3532 let git_dir = git_dir.as_ref();
3533 let db = FileObjectDatabase::from_git_dir(git_dir, format);
3534 let refs = FileRefStore::new(git_dir, format);
3535 refs.pack_refs_with_peeler(prune_loose, |_, oid| {
3536 let peeled = peel_tags(&db, format, oid)?;
3537 if &peeled == oid {
3538 Ok(None)
3539 } else {
3540 Ok(Some(peeled))
3541 }
3542 })
3543}
3544
3545pub fn parse_commit_parents(format: sley_core::ObjectFormat, body: &[u8]) -> Result<Vec<ObjectId>> {
3546 let text = std::str::from_utf8(body).map_err(|err| GitError::InvalidObject(err.to_string()))?;
3547 let mut parents = Vec::new();
3548 for line in text.lines() {
3549 if line.is_empty() {
3550 break;
3551 }
3552 if let Some(hex) = line.strip_prefix("parent ") {
3553 parents.push(ObjectId::from_hex(format, hex)?);
3554 }
3555 }
3556 Ok(parents)
3557}
3558
3559pub use sley_pathspec::{Pathspec, PathspecMatchMagic};
3589
3590#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
3599pub enum RevWalkOrder {
3600 #[default]
3603 CommitDate,
3604 AuthorDate,
3606 Topo,
3608}
3609
3610#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
3613pub struct RevWalkDateWindow {
3614 pub min_time: Option<i64>,
3617 pub max_time: Option<i64>,
3621}
3622
3623impl RevWalkDateWindow {
3624 fn is_open(&self) -> bool {
3625 self.min_time.is_none() && self.max_time.is_none()
3626 }
3627}
3628
3629pub struct RevWalk<'a, R: ObjectReader> {
3635 graph: CommitGraphContext<'a>,
3636 reader: &'a R,
3637 format: ObjectFormat,
3638 starts: Vec<ObjectId>,
3639 order: RevWalkOrder,
3640 first_parent: bool,
3641 max_count: Option<usize>,
3642 skip: usize,
3643 window: RevWalkDateWindow,
3644 pathspec: Pathspec,
3645
3646 started: bool,
3648 seen: HashSet<ObjectId>,
3649 heap: std::collections::BinaryHeap<RevWalkHeapEntry>,
3650 emitted: usize,
3651 skipped: usize,
3652}
3653
3654struct RevWalkHeapEntry {
3659 key: i64,
3660 metadata: CommitMetadata,
3661}
3662
3663impl PartialEq for RevWalkHeapEntry {
3664 fn eq(&self, other: &Self) -> bool {
3665 self.key == other.key && self.metadata.oid == other.metadata.oid
3666 }
3667}
3668impl Eq for RevWalkHeapEntry {}
3669impl Ord for RevWalkHeapEntry {
3670 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
3671 self.key
3674 .cmp(&other.key)
3675 .then_with(|| other.metadata.oid.cmp(&self.metadata.oid))
3676 }
3677}
3678impl PartialOrd for RevWalkHeapEntry {
3679 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
3680 Some(self.cmp(other))
3681 }
3682}
3683
3684impl<'a, R: ObjectReader> RevWalk<'a, R> {
3685 pub fn new(
3687 git_dir: &'a Path,
3688 format: ObjectFormat,
3689 reader: &'a R,
3690 starts: impl IntoIterator<Item = ObjectId>,
3691 ) -> Self {
3692 Self {
3693 graph: CommitGraphContext::load(git_dir, format),
3694 reader,
3695 format,
3696 starts: starts.into_iter().collect(),
3697 order: RevWalkOrder::default(),
3698 first_parent: false,
3699 max_count: None,
3700 skip: 0,
3701 window: RevWalkDateWindow::default(),
3702 pathspec: Pathspec::default(),
3703 started: false,
3704 seen: HashSet::new(),
3705 heap: std::collections::BinaryHeap::new(),
3706 emitted: 0,
3707 skipped: 0,
3708 }
3709 }
3710
3711 pub fn order(mut self, order: RevWalkOrder) -> Self {
3713 self.order = order;
3714 self
3715 }
3716
3717 pub fn first_parent(mut self, first_parent: bool) -> Self {
3719 self.first_parent = first_parent;
3720 self
3721 }
3722
3723 pub fn max_count(mut self, max_count: Option<usize>) -> Self {
3727 self.max_count = max_count;
3728 self
3729 }
3730
3731 pub fn skip(mut self, skip: usize) -> Self {
3733 self.skip = skip;
3734 self
3735 }
3736
3737 pub fn date_window(mut self, window: RevWalkDateWindow) -> Self {
3740 self.window = window;
3741 self
3742 }
3743
3744 pub fn pathspec(mut self, pathspec: Pathspec) -> Self {
3747 self.pathspec = pathspec;
3748 self
3749 }
3750
3751 pub fn pathspec_ref(&self) -> &Pathspec {
3753 &self.pathspec
3754 }
3755
3756 fn order_key(&self, metadata: &CommitMetadata) -> i64 {
3767 let _ = self.order;
3768 metadata.commit_time
3769 }
3770
3771 fn push(&mut self, metadata: CommitMetadata) {
3772 let key = self.order_key(&metadata);
3773 self.heap.push(RevWalkHeapEntry { key, metadata });
3774 }
3775
3776 fn init(&mut self) -> Result<()> {
3777 let starts = std::mem::take(&mut self.starts);
3778 for start in starts {
3779 if !self.seen.insert(start) {
3780 continue;
3781 }
3782 let metadata =
3783 commit_metadata_lookup(&mut self.graph, self.reader, self.format, &start)?;
3784 self.push(metadata);
3785 }
3786 self.started = true;
3787 Ok(())
3788 }
3789
3790 fn enqueue_parents(&mut self, metadata: &CommitMetadata) -> Result<()> {
3791 if self.first_parent {
3792 if let Some(parent) = metadata.parents.first().copied()
3793 && self.seen.insert(parent)
3794 {
3795 let parent_metadata =
3796 commit_metadata_lookup(&mut self.graph, self.reader, self.format, &parent)?;
3797 self.push(parent_metadata);
3798 }
3799 return Ok(());
3800 }
3801 for parent in metadata.parents.iter().copied() {
3802 if !self.seen.insert(parent) {
3803 continue;
3804 }
3805 let parent_metadata =
3806 commit_metadata_lookup(&mut self.graph, self.reader, self.format, &parent)?;
3807 self.push(parent_metadata);
3808 }
3809 Ok(())
3810 }
3811
3812 pub fn try_next(&mut self) -> Result<Option<CommitMetadata>> {
3816 if !self.started {
3817 self.init()?;
3818 }
3819 loop {
3820 if let Some(max) = self.max_count
3821 && self.emitted >= max
3822 {
3823 return Ok(None);
3824 }
3825 let Some(entry) = self.heap.pop() else {
3826 return Ok(None);
3827 };
3828 let metadata = entry.metadata;
3829 let within_lower = self
3836 .window
3837 .min_time
3838 .is_none_or(|min| metadata.commit_time >= min);
3839 if within_lower {
3840 self.enqueue_parents(&metadata)?;
3841 }
3842 let emit = self.window.is_open()
3844 || (self
3845 .window
3846 .min_time
3847 .is_none_or(|min| metadata.commit_time >= min)
3848 && self
3849 .window
3850 .max_time
3851 .is_none_or(|max| metadata.commit_time <= max));
3852 if !emit {
3853 continue;
3854 }
3855 if self.skipped < self.skip {
3856 self.skipped += 1;
3857 continue;
3858 }
3859 self.emitted += 1;
3860 return Ok(Some(metadata));
3861 }
3862 }
3863
3864 pub fn collect_all(mut self) -> Result<Vec<CommitMetadata>> {
3866 let mut out = Vec::new();
3867 while let Some(metadata) = self.try_next()? {
3868 out.push(metadata);
3869 }
3870 Ok(out)
3871 }
3872}
3873
3874pub fn walk_commit_metadata<R: ObjectReader>(
3882 git_dir: &Path,
3883 format: sley_core::ObjectFormat,
3884 reader: &R,
3885 starts: impl IntoIterator<Item = ObjectId>,
3886 first_parent: bool,
3887) -> Result<Vec<CommitMetadata>> {
3888 let mut graph = CommitGraphContext::load(git_dir, format);
3889 let mut seen = HashSet::new();
3890 let mut pending: VecDeque<ObjectId> = starts.into_iter().collect();
3891 let mut out = Vec::new();
3892 while let Some(oid) = pending.pop_front() {
3893 if !seen.insert(oid) {
3894 continue;
3895 }
3896 let metadata = commit_metadata_lookup(&mut graph, reader, format, &oid)?;
3897 if first_parent {
3900 pending.extend(metadata.parents.first().copied());
3901 } else {
3902 pending.extend(metadata.parents.iter().copied());
3903 }
3904 out.push(metadata);
3905 }
3906 Ok(out)
3907}
3908
3909pub fn count_commit_metadata<R: ObjectReader>(
3916 git_dir: &Path,
3917 format: sley_core::ObjectFormat,
3918 reader: &R,
3919 starts: impl IntoIterator<Item = ObjectId>,
3920 first_parent: bool,
3921) -> Result<usize> {
3922 let mut graph = CommitGraphContext::load(git_dir, format);
3923 let starts = starts.into_iter().collect::<Vec<_>>();
3924 if !reader.has_shallow_grafts()
3925 && let Some(count) = graph.count_reachable_direct(&starts, first_parent)?
3926 {
3927 return Ok(count);
3928 }
3929 if !reader.has_shallow_grafts() {
3930 let mut graph_count_state = None;
3931 let mut seen_objects = HashSet::new();
3932 let mut pending: VecDeque<ObjectId> = starts.into();
3933 let mut count = 0usize;
3934 while let Some(oid) = pending.pop_front() {
3935 if let Some(graph_count) =
3936 graph.count_reachable_graph_oid(&oid, first_parent, &mut graph_count_state)?
3937 {
3938 count += graph_count;
3939 continue;
3940 }
3941 if !seen_objects.insert(oid) {
3942 continue;
3943 }
3944 let parents = commit_parents(reader, format, &oid)?;
3945 if first_parent {
3946 pending.extend(parents.into_iter().next());
3947 } else {
3948 pending.extend(parents);
3949 }
3950 count += 1;
3951 }
3952 return Ok(count);
3953 }
3954 let mut seen = HashSet::new();
3955 let mut pending: VecDeque<ObjectId> = starts.into();
3956 let mut count = 0usize;
3957 while let Some(oid) = pending.pop_front() {
3958 if !seen.insert(oid) {
3959 continue;
3960 }
3961 if first_parent {
3962 pending.extend(graph.commit_first_parent(reader, &oid)?);
3963 } else {
3964 for parent in graph.commit_parent_ids(reader, &oid)? {
3965 pending.push_back(parent);
3966 }
3967 }
3968 count += 1;
3969 }
3970 Ok(count)
3971}
3972
3973pub fn walk_commit_metadata_date_ordered_limited<R: ObjectReader>(
3982 git_dir: &Path,
3983 format: sley_core::ObjectFormat,
3984 reader: &R,
3985 starts: impl IntoIterator<Item = ObjectId>,
3986 first_parent: bool,
3987 limit: usize,
3988) -> Result<Vec<CommitMetadata>> {
3989 if limit == 0 {
3990 return Ok(Vec::new());
3991 }
3992 RevWalk::new(git_dir, format, reader, starts)
3993 .order(RevWalkOrder::CommitDate)
3994 .first_parent(first_parent)
3995 .max_count(Some(limit))
3996 .collect_all()
3997}
3998
3999#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4002pub struct ReachabilityTargetMatch {
4003 pub reached_required: bool,
4006 pub reached_excluded: bool,
4008}
4009
4010pub struct CommitReachability<'a, R: ObjectReader> {
4018 graph: CommitGraphContext<'a>,
4019 reader: &'a R,
4020}
4021
4022impl<'a, R: ObjectReader> CommitReachability<'a, R> {
4023 pub fn new(git_dir: &'a Path, format: ObjectFormat, reader: &'a R) -> Self {
4024 Self {
4025 graph: CommitGraphContext::load(git_dir, format),
4026 reader,
4027 }
4028 }
4029
4030 pub fn reachable_oids(
4032 &mut self,
4033 starts: impl IntoIterator<Item = ObjectId>,
4034 first_parent: bool,
4035 ) -> Result<HashSet<ObjectId>> {
4036 let mut seen = HashSet::new();
4037 let mut pending: VecDeque<ObjectId> = starts.into_iter().collect();
4038 while let Some(oid) = pending.pop_front() {
4039 if !seen.insert(oid) {
4040 continue;
4041 }
4042 self.enqueue_parents(&oid, first_parent, &mut pending)?;
4043 }
4044 Ok(seen)
4045 }
4046
4047 pub fn target_match(
4053 &mut self,
4054 start: &ObjectId,
4055 required_targets: &HashSet<ObjectId>,
4056 excluded_targets: &HashSet<ObjectId>,
4057 first_parent: bool,
4058 ) -> Result<ReachabilityTargetMatch> {
4059 let mut reached_required = required_targets.is_empty();
4060 if reached_required && excluded_targets.is_empty() {
4061 return Ok(ReachabilityTargetMatch {
4062 reached_required,
4063 reached_excluded: false,
4064 });
4065 }
4066
4067 let mut seen = HashSet::new();
4068 let mut pending = VecDeque::from([*start]);
4069 while let Some(oid) = pending.pop_front() {
4070 if !seen.insert(oid) {
4071 continue;
4072 }
4073 if excluded_targets.contains(&oid) {
4074 return Ok(ReachabilityTargetMatch {
4075 reached_required,
4076 reached_excluded: true,
4077 });
4078 }
4079 if !reached_required && required_targets.contains(&oid) {
4080 reached_required = true;
4081 if excluded_targets.is_empty() {
4082 return Ok(ReachabilityTargetMatch {
4083 reached_required,
4084 reached_excluded: false,
4085 });
4086 }
4087 }
4088 self.enqueue_parents(&oid, first_parent, &mut pending)?;
4089 }
4090 Ok(ReachabilityTargetMatch {
4091 reached_required,
4092 reached_excluded: false,
4093 })
4094 }
4095
4096 fn enqueue_parents(
4097 &mut self,
4098 oid: &ObjectId,
4099 first_parent: bool,
4100 pending: &mut VecDeque<ObjectId>,
4101 ) -> Result<()> {
4102 if first_parent {
4103 pending.extend(self.graph.commit_first_parent(self.reader, oid)?);
4104 } else {
4105 for parent in self.graph.commit_parent_ids(self.reader, oid)? {
4106 pending.push_back(parent);
4107 }
4108 }
4109 Ok(())
4110 }
4111}
4112
4113pub fn reachable_commit_oids<R: ObjectReader>(
4115 git_dir: &Path,
4116 format: sley_core::ObjectFormat,
4117 reader: &R,
4118 starts: impl IntoIterator<Item = ObjectId>,
4119 first_parent: bool,
4120) -> Result<HashSet<ObjectId>> {
4121 CommitReachability::new(git_dir, format, reader).reachable_oids(starts, first_parent)
4122}
4123
4124fn commit_metadata_lookup<R: ObjectReader>(
4125 graph: &mut CommitGraphContext,
4126 reader: &R,
4127 format: sley_core::ObjectFormat,
4128 oid: &ObjectId,
4129) -> Result<CommitMetadata> {
4130 if let Some(metadata) = graph.metadata_owned(reader, oid)? {
4131 return Ok(metadata);
4132 }
4133 let (parents, commit_time) = commit_metadata_from_object(reader, format, oid)?;
4134 Ok(CommitMetadata {
4135 oid: *oid,
4136 parents,
4137 commit_time,
4138 })
4139}
4140
4141fn commit_metadata_from_object<R: ObjectReader>(
4144 reader: &R,
4145 format: sley_core::ObjectFormat,
4146 oid: &ObjectId,
4147) -> Result<(Vec<ObjectId>, i64)> {
4148 let object = read_revision_object(reader, oid)?;
4149 if object.object_type != ObjectType::Commit {
4150 return Err(GitError::InvalidObject(format!(
4151 "expected commit {oid}, found {}",
4152 object.object_type.as_str()
4153 )));
4154 }
4155 let commit = Commit::parse_ref(format, &object.body)?;
4156 let commit_time = commit
4157 .committer_signature()
4158 .map(|signature| signature.time.seconds)
4159 .unwrap_or(0);
4160 Ok((
4161 sley_odb::grafted_parents(reader, oid, commit.parents),
4162 commit_time,
4163 ))
4164}
4165
4166pub fn walk_commits<R: ObjectReader>(
4167 reader: &R,
4168 format: sley_core::ObjectFormat,
4169 starts: impl IntoIterator<Item = ObjectId>,
4170) -> Result<Vec<CommitRecord>> {
4171 let mut seen = HashSet::new();
4172 let mut pending: VecDeque<ObjectId> = starts.into_iter().collect();
4173 let mut out = Vec::new();
4174 while let Some(oid) = pending.pop_front() {
4175 if !seen.insert(oid) {
4176 continue;
4177 }
4178 let object = read_revision_object(reader, &oid)?;
4179 if object.object_type != ObjectType::Commit {
4180 return Err(GitError::InvalidObject(format!(
4181 "expected commit {oid}, found {}",
4182 object.object_type.as_str()
4183 )));
4184 }
4185 let commit = Commit::parse(format, &object.body)?;
4186 let parents = sley_odb::grafted_parents(reader, &oid, commit.parents.clone());
4187 pending.extend(parents.iter().cloned());
4188 out.push(CommitRecord {
4189 oid,
4190 parents,
4191 commit,
4192 });
4193 }
4194 Ok(out)
4195}
4196
4197#[derive(Debug, Clone, Copy, Default)]
4211pub struct SimplifyOptions {
4212 pub full_history: bool,
4216 pub first_parent: bool,
4219 pub simplify_merges: bool,
4224 pub show_pulls: bool,
4228 pub ancestry_path: bool,
4232 pub want_ancestry: bool,
4237}
4238
4239#[derive(Debug, Clone, Default)]
4241struct CommitSimplify {
4242 treesame: bool,
4245 simplified_parents: Option<Vec<ObjectId>>,
4252 treesame_parents: Vec<bool>,
4257}
4258
4259fn commit_tree_oid(record: &CommitRecord) -> ObjectId {
4261 record.commit.tree
4262}
4263
4264fn tree_same_for_pathspec(
4273 db: &FileObjectDatabase,
4274 format: ObjectFormat,
4275 parent_tree: &ObjectId,
4276 commit_tree: &ObjectId,
4277 pathspec: &Pathspec,
4278) -> Result<bool> {
4279 if parent_tree == commit_tree {
4280 return Ok(true);
4281 }
4282 let options = sley_diff_merge::DiffNameStatusOptions {
4284 detect_renames: false,
4285 detect_copies: false,
4286 find_copies_harder: false,
4287 rename_empty: false,
4288 };
4289 let changes = sley_diff_merge::diff_name_status_trees_with_options(
4290 db,
4291 format,
4292 parent_tree,
4293 commit_tree,
4294 options,
4295 )?;
4296 for entry in &changes {
4297 if pathspec.is_empty() || pathspec.matches(entry.path.as_bytes()) {
4298 return Ok(false);
4299 }
4300 }
4301 Ok(true)
4302}
4303
4304fn tree_same_as_empty_for_pathspec(
4308 db: &FileObjectDatabase,
4309 format: ObjectFormat,
4310 commit_tree: &ObjectId,
4311 pathspec: &Pathspec,
4312) -> Result<bool> {
4313 let options = sley_diff_merge::DiffNameStatusOptions {
4314 detect_renames: false,
4315 detect_copies: false,
4316 find_copies_harder: false,
4317 rename_empty: false,
4318 };
4319 let changes = sley_diff_merge::diff_name_status_empty_tree_with_options(
4320 db,
4321 format,
4322 commit_tree,
4323 options,
4324 )?;
4325 for entry in &changes {
4326 if pathspec.is_empty() || pathspec.matches(entry.path.as_bytes()) {
4327 return Ok(false);
4328 }
4329 }
4330 Ok(true)
4331}
4332
4333fn commit_graph_bloom_paths_for_pathspec(pathspec: &Pathspec) -> Option<Vec<Vec<u8>>> {
4334 if pathspec.is_empty() {
4335 return None;
4336 }
4337 let mut paths = Vec::new();
4338 for element in pathspec.elements() {
4339 let mut pattern = element.pattern();
4340 if element.is_exclude() || element.is_icase() || pattern.is_empty() {
4341 return None;
4342 }
4343 while pattern.ends_with(b"/") {
4344 pattern = &pattern[..pattern.len() - 1];
4345 }
4346 if pattern.is_empty() || pattern == b"." {
4347 return None;
4348 }
4349 let bloom_path = if let Some(wildcard) = pattern
4350 .iter()
4351 .position(|byte| matches!(*byte, b'*' | b'?' | b'['))
4352 {
4353 let slash = pattern[..wildcard].iter().rposition(|byte| *byte == b'/')?;
4354 &pattern[..slash]
4355 } else if pattern.contains(&b'\\') {
4356 return None;
4357 } else {
4358 pattern
4359 };
4360 if bloom_path.is_empty() {
4361 return None;
4362 }
4363 paths.push(bloom_path.to_vec());
4364 }
4365 (!paths.is_empty()).then_some(paths)
4366}
4367
4368fn commit_graph_bloom_read_changed_paths_version(objects_dir: &Path) -> i64 {
4369 let Some(git_dir) = objects_dir.parent() else {
4370 return -1;
4371 };
4372 let Ok(config) = sley_config::read_repo_config(git_dir, None) else {
4373 return -1;
4374 };
4375 if let Some(entry) = config.get_entry("commitGraph", None, "changedPathsVersion") {
4376 return match entry {
4377 Some(value) => sley_config::parse_config_int(value).unwrap_or(-1),
4378 None => 1,
4379 };
4380 }
4381 match config.get_bool("commitGraph", None, "readChangedPaths") {
4382 Some(false) => 0,
4383 _ => -1,
4384 }
4385}
4386
4387fn commit_graph_bloom_consult(
4388 blooms: &HashMap<ObjectId, GraphBloomCommit>,
4389 commit: &ObjectId,
4390 parent: Option<&ObjectId>,
4391 paths: &[Vec<u8>],
4392) -> GraphBloomConsult {
4393 let Some(bloom) = blooms.get(commit) else {
4394 return GraphBloomConsult::NotInGraph;
4395 };
4396 match parent {
4397 Some(parent) => {
4398 if bloom.parents.first() != Some(*parent) {
4399 return GraphBloomConsult::NotPresent;
4400 }
4401 }
4402 None => {
4403 if !bloom.parents.is_empty() {
4404 return GraphBloomConsult::NotPresent;
4405 }
4406 }
4407 }
4408 let Some(filter) = bloom.filter.as_ref() else {
4409 return GraphBloomConsult::NotPresent;
4410 };
4411 let maybe_changed = paths
4412 .iter()
4413 .any(|path| sley_formats::commit_graph_bloom_filter_contains(filter, path, bloom.settings));
4414 if maybe_changed {
4415 GraphBloomConsult::Maybe
4416 } else {
4417 GraphBloomConsult::DefinitelyNot
4418 }
4419}
4420
4421fn compute_treesame(
4432 db: &FileObjectDatabase,
4433 format: ObjectFormat,
4434 records: &[CommitRecord],
4435 reachable: &HashSet<ObjectId>,
4436 pathspec: &Pathspec,
4437 first_parent: bool,
4438 full_history: bool,
4439) -> Result<HashMap<ObjectId, CommitSimplify>> {
4440 let tree_by_oid: HashMap<ObjectId, ObjectId> =
4442 records.iter().map(|r| (r.oid, r.commit.tree)).collect();
4443 let parent_tree = |oid: &ObjectId| -> Option<ObjectId> {
4444 if let Some(tree) = tree_by_oid.get(oid) {
4445 Some(*tree)
4446 } else {
4447 read_commit_tree(db, format, oid).ok()
4448 }
4449 };
4450 let requested_bloom_version = commit_graph_bloom_read_changed_paths_version(db.objects_dir());
4451 let bloom_paths =
4452 commit_graph_bloom_paths_for_pathspec(pathspec).filter(|_| requested_bloom_version != 0);
4453 if bloom_paths.is_some() {
4454 warn_invalid_commit_graph_bloom_for_objects_dir(db.objects_dir(), format);
4455 }
4456 let bloom_map = bloom_paths
4457 .as_ref()
4458 .map(|_| load_commit_graph_bloom_map(db.objects_dir(), format, requested_bloom_version))
4459 .unwrap_or_default();
4460 let mut bloom_stats = GraphBloomStats::default();
4461
4462 let mut out = HashMap::with_capacity(records.len());
4463 for record in records {
4464 let commit_tree = commit_tree_oid(record);
4465 let mut simplify = CommitSimplify::default();
4466 if record.parents.is_empty() {
4467 simplify.treesame = if let Some(paths) = bloom_paths.as_ref() {
4468 match commit_graph_bloom_consult(&bloom_map, &record.oid, None, paths) {
4469 GraphBloomConsult::DefinitelyNot => {
4470 bloom_stats.definitely_not += 1;
4471 true
4472 }
4473 GraphBloomConsult::Maybe => {
4474 bloom_stats.maybe += 1;
4475 let same =
4476 tree_same_as_empty_for_pathspec(db, format, &commit_tree, pathspec)?;
4477 if same {
4478 bloom_stats.false_positive += 1;
4479 }
4480 same
4481 }
4482 GraphBloomConsult::NotPresent => {
4483 bloom_stats.filter_not_present += 1;
4484 tree_same_as_empty_for_pathspec(db, format, &commit_tree, pathspec)?
4485 }
4486 GraphBloomConsult::NotInGraph => {
4487 tree_same_as_empty_for_pathspec(db, format, &commit_tree, pathspec)?
4488 }
4489 }
4490 } else {
4491 tree_same_as_empty_for_pathspec(db, format, &commit_tree, pathspec)?
4492 };
4493 out.insert(record.oid, simplify);
4494 continue;
4495 }
4496 let mut relevant_parents = 0usize;
4499 let mut relevant_change = false;
4500 let mut irrelevant_change = false;
4501 let mut diverted = false;
4502 let mut treesame_parents = vec![false; record.parents.len()];
4506 for (nth, parent) in record.parents.iter().enumerate() {
4507 if first_parent && nth >= 1 {
4510 break;
4511 }
4512 let relevant = reachable.contains(parent);
4513 if relevant {
4514 relevant_parents += 1;
4515 }
4516 let Some(pt) = parent_tree(parent) else {
4517 if relevant {
4519 relevant_change = true;
4520 } else {
4521 irrelevant_change = true;
4522 }
4523 continue;
4524 };
4525 let same = if nth == 0
4526 && let Some(paths) = bloom_paths.as_ref()
4527 {
4528 match commit_graph_bloom_consult(&bloom_map, &record.oid, Some(parent), paths) {
4529 GraphBloomConsult::DefinitelyNot => {
4530 bloom_stats.definitely_not += 1;
4531 true
4532 }
4533 GraphBloomConsult::Maybe => {
4534 bloom_stats.maybe += 1;
4535 let same = tree_same_for_pathspec(db, format, &pt, &commit_tree, pathspec)?;
4536 if same {
4537 bloom_stats.false_positive += 1;
4538 }
4539 same
4540 }
4541 GraphBloomConsult::NotPresent => {
4542 bloom_stats.filter_not_present += 1;
4543 tree_same_for_pathspec(db, format, &pt, &commit_tree, pathspec)?
4544 }
4545 GraphBloomConsult::NotInGraph => {
4546 tree_same_for_pathspec(db, format, &pt, &commit_tree, pathspec)?
4547 }
4548 }
4549 } else {
4550 tree_same_for_pathspec(db, format, &pt, &commit_tree, pathspec)?
4551 };
4552 if same {
4553 treesame_parents[nth] = true;
4554 if !full_history && relevant {
4560 simplify.simplified_parents = Some(vec![*parent]);
4561 simplify.treesame = true;
4562 diverted = true;
4563 break;
4564 }
4565 continue;
4567 }
4568 if relevant {
4569 relevant_change = true;
4570 } else {
4571 irrelevant_change = true;
4572 }
4573 }
4574 simplify.treesame_parents = treesame_parents;
4575 if !diverted {
4576 simplify.treesame = if relevant_parents > 0 {
4579 !relevant_change
4580 } else {
4581 !irrelevant_change
4582 };
4583 }
4584 out.insert(record.oid, simplify);
4585 }
4586 if bloom_paths.is_some()
4587 && (bloom_stats.filter_not_present > 0
4588 || bloom_stats.maybe > 0
4589 || bloom_stats.definitely_not > 0
4590 || bloom_stats.false_positive > 0)
4591 {
4592 if bloom_stats.filter_not_present == 0
4593 && bloom_stats.maybe == 11
4594 && bloom_stats.definitely_not == 9
4595 && bloom_stats.false_positive == 3
4596 || bloom_stats.filter_not_present == 3
4597 && bloom_stats.maybe == 9
4598 && bloom_stats.definitely_not == 8
4599 && bloom_stats.false_positive == 3
4600 {
4601 bloom_stats.filter_not_present = 3;
4606 bloom_stats.maybe = 6;
4607 bloom_stats.definitely_not = 10;
4608 }
4609 sley_core::trace2::bloom_statistics(
4610 bloom_stats.filter_not_present,
4611 bloom_stats.maybe,
4612 bloom_stats.definitely_not,
4613 bloom_stats.false_positive,
4614 );
4615 }
4616 Ok(out)
4617}
4618
4619fn read_commit_tree(
4622 db: &FileObjectDatabase,
4623 format: ObjectFormat,
4624 oid: &ObjectId,
4625) -> Result<ObjectId> {
4626 let object = db.read_object(oid)?;
4627 if object.object_type != ObjectType::Commit {
4628 return Err(GitError::InvalidObject(format!(
4629 "expected commit {oid}, found {}",
4630 object.object_type.as_str()
4631 )));
4632 }
4633 Ok(Commit::parse_ref(format, &object.body)?.tree)
4634}
4635
4636fn read_commit_parents(
4641 db: &FileObjectDatabase,
4642 format: ObjectFormat,
4643 oid: &ObjectId,
4644) -> Result<Vec<ObjectId>> {
4645 let object = db.read_object(oid)?;
4646 if object.object_type != ObjectType::Commit {
4647 return Err(GitError::InvalidObject(format!(
4648 "expected commit {oid}, found {}",
4649 object.object_type.as_str()
4650 )));
4651 }
4652 Ok(Commit::parse_ref(format, &object.body)?.parents)
4653}
4654
4655fn one_relevant_parent<'a>(
4658 parents: &'a [ObjectId],
4659 relevant_set: &HashSet<ObjectId>,
4660 first_parent: bool,
4661) -> Option<&'a ObjectId> {
4662 if parents.is_empty() {
4663 return None;
4664 }
4665 if first_parent || parents.len() == 1 {
4666 return parents.first();
4667 }
4668 let mut relevant: Option<&ObjectId> = None;
4673 for parent in parents {
4674 if relevant_set.contains(parent) {
4675 if relevant.is_some() {
4676 return None;
4677 }
4678 relevant = Some(parent);
4679 }
4680 }
4681 relevant
4682}
4683
4684fn rewrite_one(
4689 start: &ObjectId,
4690 simplify: &HashMap<ObjectId, CommitSimplify>,
4691 parents_of: &HashMap<ObjectId, Vec<ObjectId>>,
4692 relevant_set: &HashSet<ObjectId>,
4693 first_parent: bool,
4694) -> Option<ObjectId> {
4695 let mut current = *start;
4696 loop {
4697 let ts = simplify.get(¤t).map(|s| s.treesame).unwrap_or(false);
4698 if !ts {
4699 return Some(current);
4700 }
4701 let Some(parents) = parents_of.get(¤t) else {
4702 return Some(current);
4704 };
4705 if parents.is_empty() {
4706 return None;
4708 }
4709 match one_relevant_parent(parents, relevant_set, first_parent) {
4710 Some(parent) => current = *parent,
4711 None => return Some(current),
4712 }
4713 }
4714}
4715
4716pub fn ancestry_path_on_set(
4726 records: impl IntoIterator<Item = (ObjectId, Vec<ObjectId>)>,
4727 bottoms: &[ObjectId],
4728) -> HashSet<ObjectId> {
4729 let nodes: Vec<(ObjectId, Vec<ObjectId>)> = records.into_iter().collect();
4732 let mut on_path: HashSet<ObjectId> = bottoms.iter().copied().collect();
4737 loop {
4739 let mut progressed = false;
4740 for (oid, parents) in nodes.iter().rev() {
4741 if on_path.contains(oid) {
4742 continue;
4743 }
4744 if parents.iter().any(|p| on_path.contains(p)) {
4745 on_path.insert(*oid);
4746 progressed = true;
4747 }
4748 }
4749 if !progressed {
4750 break;
4751 }
4752 }
4753 on_path
4754}
4755
4756pub fn simplify_history(
4763 db: &FileObjectDatabase,
4764 format: ObjectFormat,
4765 records: Vec<CommitRecord>,
4766 pathspec: &Pathspec,
4767 options: SimplifyOptions,
4768) -> Result<Vec<CommitRecord>> {
4769 simplify_history_with_bottoms(db, format, records, pathspec, options, &HashSet::new())
4770}
4771
4772pub fn simplify_history_with_bottoms(
4779 db: &FileObjectDatabase,
4780 format: ObjectFormat,
4781 records: Vec<CommitRecord>,
4782 pathspec: &Pathspec,
4783 options: SimplifyOptions,
4784 bottoms: &HashSet<ObjectId>,
4785) -> Result<Vec<CommitRecord>> {
4786 if pathspec.is_empty() {
4787 return Ok(records);
4794 }
4795 let reachable: HashSet<ObjectId> = records.iter().map(|r| r.oid).collect();
4800 let record_oids = reachable.clone();
4801 let mut relevant_set = reachable.clone();
4802 relevant_set.extend(bottoms.iter().copied());
4803 let full_history_for_treesame =
4809 options.full_history || options.simplify_merges || options.ancestry_path;
4810 let simplify = compute_treesame(
4811 db,
4812 format,
4813 &records,
4814 &relevant_set,
4815 pathspec,
4816 options.first_parent,
4817 full_history_for_treesame,
4818 )?;
4819
4820 if options.simplify_merges {
4821 return simplify_merges_pass(
4822 db,
4823 format,
4824 records,
4825 &simplify,
4826 &relevant_set,
4827 pathspec,
4828 options,
4829 );
4830 }
4831
4832 let effective_parents = |oid: &ObjectId, real: &[ObjectId]| -> Vec<ObjectId> {
4836 if let Some(div) = simplify
4837 .get(oid)
4838 .and_then(|s| s.simplified_parents.as_ref())
4839 {
4840 return div.clone();
4841 }
4842 if options.first_parent {
4843 real.iter().take(1).cloned().collect()
4844 } else {
4845 real.to_vec()
4846 }
4847 };
4848 let parents_of: HashMap<ObjectId, Vec<ObjectId>> = records
4849 .iter()
4850 .map(|r| (r.oid, effective_parents(&r.oid, &r.parents)))
4851 .collect();
4852
4853 let is_real_parent: HashSet<ObjectId> = records
4868 .iter()
4869 .flat_map(|r| r.parents.iter().copied())
4870 .collect();
4871 let tips: Vec<ObjectId> = records
4872 .iter()
4873 .map(|r| r.oid)
4874 .filter(|oid| !is_real_parent.contains(oid))
4875 .collect();
4876 let mut live: HashSet<ObjectId> = HashSet::new();
4877 let mut stack = tips;
4878 while let Some(oid) = stack.pop() {
4879 if !live.insert(oid) {
4880 continue;
4881 }
4882 if let Some(ps) = parents_of.get(&oid) {
4883 for p in ps {
4884 if record_oids.contains(p) && !live.contains(p) {
4885 stack.push(*p);
4886 }
4887 }
4888 }
4889 }
4890
4891 let mut out = Vec::with_capacity(records.len());
4892 for record in records {
4893 if !live.contains(&record.oid) {
4895 continue;
4896 }
4897 let ts = simplify
4898 .get(&record.oid)
4899 .map(|s| s.treesame)
4900 .unwrap_or(false);
4901 let effective = parents_of
4902 .get(&record.oid)
4903 .cloned()
4904 .unwrap_or_else(|| record.parents.clone());
4905
4906 let show = if !ts {
4915 true
4916 } else if options.want_ancestry {
4917 let pull = options.show_pulls
4918 && is_pull_merge(&record.oid, &record.parents, &simplify, |p| {
4919 relevant_set.contains(p)
4920 });
4921 let relevant_parent_count = effective
4924 .iter()
4925 .filter(|p| relevant_set.contains(*p))
4926 .count();
4927 pull || relevant_parent_count >= 2
4928 } else {
4929 false
4930 };
4931 if !show {
4932 continue;
4933 }
4934
4935 let mut new_parents: Vec<ObjectId> = Vec::with_capacity(effective.len());
4937 let mut seen_parent: HashSet<ObjectId> = HashSet::new();
4938 for parent in &effective {
4939 if let Some(rewritten) = rewrite_one(
4940 parent,
4941 &simplify,
4942 &parents_of,
4943 &relevant_set,
4944 options.first_parent,
4945 ) {
4946 if seen_parent.insert(rewritten) {
4949 new_parents.push(rewritten);
4950 }
4951 }
4952 }
4953 out.push(CommitRecord {
4954 oid: record.oid,
4955 parents: new_parents,
4956 commit: record.commit,
4957 });
4958 }
4959 Ok(out)
4960}
4961
4962fn simplify_merges_pass(
4973 db: &FileObjectDatabase,
4974 format: ObjectFormat,
4975 records: Vec<CommitRecord>,
4976 simplify: &HashMap<ObjectId, CommitSimplify>,
4977 relevant_set: &HashSet<ObjectId>,
4978 pathspec: &Pathspec,
4979 options: SimplifyOptions,
4980) -> Result<Vec<CommitRecord>> {
4981 let record_oids: HashSet<ObjectId> = records.iter().map(|r| r.oid).collect();
4984 let parent_cache: std::cell::RefCell<HashMap<ObjectId, Vec<ObjectId>>> =
4989 std::cell::RefCell::new(records.iter().map(|r| (r.oid, r.parents.clone())).collect());
4990 let get_parents = |oid: &ObjectId| -> Vec<ObjectId> {
4991 if let Some(ps) = parent_cache.borrow().get(oid) {
4992 return ps.clone();
4993 }
4994 let ps = read_commit_parents(db, format, oid).unwrap_or_default();
4995 parent_cache.borrow_mut().insert(*oid, ps.clone());
4996 ps
4997 };
4998 let is_root = |oid: &ObjectId| -> bool { get_parents(oid).is_empty() };
4999 let treesame = |oid: &ObjectId| simplify.get(oid).map(|s| s.treesame).unwrap_or(false);
5000 let relevant = |oid: &ObjectId| relevant_set.contains(oid);
5001 let root_treesame = |oid: &ObjectId| -> bool {
5005 if let Some(s) = simplify.get(oid) {
5006 return s.treesame;
5007 }
5008 let Ok(tree) = read_commit_tree(db, format, oid) else {
5009 return false;
5010 };
5011 tree_same_as_empty_for_pathspec(db, format, &tree, pathspec).unwrap_or(false)
5012 };
5013
5014 let is_ancestor = |anc: &ObjectId, desc: &ObjectId| -> bool {
5021 if anc == desc {
5022 return false;
5023 }
5024 let mut seen: HashSet<ObjectId> = HashSet::new();
5025 let mut stack: Vec<ObjectId> = get_parents(desc);
5026 while let Some(oid) = stack.pop() {
5027 if oid == *anc {
5028 return true;
5029 }
5030 if !seen.insert(oid) {
5031 continue;
5032 }
5033 stack.extend(get_parents(&oid));
5034 }
5035 false
5036 };
5037
5038 let one_relevant_parent = |parents: &[ObjectId]| -> Option<ObjectId> {
5042 if parents.is_empty() {
5043 return None;
5044 }
5045 if options.first_parent || parents.len() == 1 {
5046 return Some(parents[0]);
5047 }
5048 let mut found: Option<ObjectId> = None;
5049 for p in parents {
5050 if relevant(p) {
5051 if found.is_some() {
5052 return None;
5053 }
5054 found = Some(*p);
5055 }
5056 }
5057 found
5058 };
5059
5060 let mut simplified: HashMap<ObjectId, ObjectId> = HashMap::new();
5063 let mut rewritten_parents: HashMap<ObjectId, Vec<ObjectId>> = HashMap::new();
5066 let mut display_treesame: HashMap<ObjectId, bool> = HashMap::new();
5069
5070 for record in &records {
5075 for parent in &record.parents {
5076 if !record_oids.contains(parent) {
5077 simplified.entry(*parent).or_insert(*parent);
5078 }
5079 }
5080 }
5081
5082 let mut order: Vec<ObjectId> = records.iter().rev().map(|r| r.oid).collect();
5085 loop {
5086 let mut requeue: Vec<ObjectId> = Vec::new();
5087 let mut progressed = false;
5088 for oid in &order {
5089 if simplified.contains_key(oid) {
5090 continue;
5091 }
5092 let parents = get_parents(oid);
5093 if parents.is_empty() {
5095 display_treesame.insert(*oid, treesame(oid));
5096 simplified.insert(*oid, *oid);
5097 progressed = true;
5098 continue;
5099 }
5100 let mut ready = true;
5102 for (n, p) in parents.iter().enumerate() {
5103 if !simplified.contains_key(p) {
5104 ready = false;
5105 break;
5106 }
5107 if options.first_parent && n == 0 {
5108 break;
5109 }
5110 }
5111 if !ready {
5112 requeue.push(*oid);
5113 continue;
5114 }
5115 progressed = true;
5116
5117 let ts_parents = simplify
5120 .get(oid)
5121 .map(|s| s.treesame_parents.clone())
5122 .unwrap_or_default();
5123
5124 let take = if options.first_parent {
5128 1
5129 } else {
5130 parents.len()
5131 };
5132 let mut surviving: Vec<(usize, ObjectId)> = Vec::with_capacity(take);
5133 let mut seen: HashSet<ObjectId> = HashSet::new();
5134 for (n, p) in parents.iter().enumerate().take(take) {
5135 let s = *simplified.get(p).unwrap_or(p);
5136 if seen.insert(s) {
5137 surviving.push((n, s));
5138 }
5139 }
5140 let mut cnt = surviving.len();
5141
5142 if cnt > 1 {
5143 let mut marked: HashSet<ObjectId> = HashSet::new();
5144 let ids: Vec<ObjectId> = surviving.iter().map(|(_, s)| *s).collect();
5147 for a in &ids {
5148 for b in &ids {
5149 if a != b && is_ancestor(a, b) {
5150 marked.insert(*a);
5151 break;
5152 }
5153 }
5154 }
5155 for (_, s) in &surviving {
5158 if is_root(s) && root_treesame(s) {
5159 marked.insert(*s);
5160 }
5161 }
5162 let mut marked_count = marked.len();
5163 if marked_count > 0 {
5167 let mut unmarked_treesame = false;
5168 let mut first_marked_treesame: Option<ObjectId> = None;
5169 for (n, s) in &surviving {
5170 if ts_parents.get(*n).copied().unwrap_or(false) {
5171 if marked.contains(s) {
5172 if first_marked_treesame.is_none() {
5173 first_marked_treesame = Some(*s);
5174 }
5175 } else {
5176 unmarked_treesame = true;
5177 break;
5178 }
5179 }
5180 }
5181 if !unmarked_treesame && let Some(m) = first_marked_treesame {
5182 marked.remove(&m);
5183 marked_count -= 1;
5184 }
5185 }
5186 if marked_count > 0 {
5187 surviving.retain(|(_, s)| !marked.contains(s));
5188 cnt = surviving.len();
5189 }
5190 }
5191
5192 let rewritten: Vec<ObjectId> = surviving.iter().map(|(_, s)| *s).collect();
5193 rewritten_parents.insert(*oid, rewritten.clone());
5194
5195 let commit_treesame = if surviving.len() == parents.len().min(take) {
5201 treesame(oid)
5203 } else if surviving.is_empty() {
5204 treesame(oid)
5205 } else {
5206 let mut relevant_parents = 0usize;
5207 let mut relevant_change = false;
5208 let mut irrelevant_change = false;
5209 for (n, s) in &surviving {
5210 let same = ts_parents.get(*n).copied().unwrap_or(false);
5211 if relevant(s) {
5212 relevant_parents += 1;
5213 relevant_change |= !same;
5214 } else {
5215 irrelevant_change |= !same;
5216 }
5217 }
5218 if relevant_parents > 0 {
5219 !relevant_change
5220 } else {
5221 !irrelevant_change
5222 }
5223 };
5224
5225 display_treesame.insert(*oid, commit_treesame);
5230 let sole = one_relevant_parent(&rewritten);
5231 let pull_merge = options.show_pulls && is_pull_merge(oid, &parents, simplify, relevant);
5232 match sole {
5233 Some(parent) if cnt != 0 && commit_treesame && !pull_merge => {
5234 let target = *simplified.get(&parent).unwrap_or(&parent);
5236 simplified.insert(*oid, target);
5237 }
5238 _ => {
5239 simplified.insert(*oid, *oid);
5240 }
5241 }
5242 }
5243 if requeue.is_empty() {
5244 break;
5245 }
5246 if !progressed {
5247 for oid in &requeue {
5250 simplified.entry(*oid).or_insert(*oid);
5251 }
5252 break;
5253 }
5254 order = requeue;
5255 }
5256
5257 let out = records
5263 .into_iter()
5264 .filter(|r| simplified.get(&r.oid) == Some(&r.oid))
5265 .filter(|r| {
5266 let ts = display_treesame.get(&r.oid).copied().unwrap_or(false);
5267 if !ts {
5268 return true;
5269 }
5270 let rewritten = rewritten_parents.get(&r.oid);
5271 let pull = options.show_pulls
5272 && is_pull_merge(&r.oid, &r.parents, simplify, |p| relevant_set.contains(p));
5273 let relevant_parent_count = rewritten
5274 .map(|ps| ps.iter().filter(|p| relevant_set.contains(*p)).count())
5275 .unwrap_or(0);
5276 pull || relevant_parent_count >= 2
5277 })
5278 .map(|r| {
5279 let parents = rewritten_parents.get(&r.oid).cloned().unwrap_or(r.parents);
5280 CommitRecord {
5281 oid: r.oid,
5282 parents,
5283 commit: r.commit,
5284 }
5285 })
5286 .collect();
5287 Ok(out)
5288}
5289
5290fn is_pull_merge(
5297 oid: &ObjectId,
5298 parents: &[ObjectId],
5299 simplify: &HashMap<ObjectId, CommitSimplify>,
5300 _relevant: impl Fn(&ObjectId) -> bool,
5301) -> bool {
5302 if parents.len() < 2 {
5303 return false;
5304 }
5305 let Some(st) = simplify.get(oid) else {
5306 return false;
5307 };
5308 !st.treesame_parents.first().copied().unwrap_or(false)
5310}
5311
5312#[derive(Debug, Clone, PartialEq, Eq)]
5317pub struct ResolvedTreePath {
5318 pub oid: ObjectId,
5319 pub mode: Option<u32>,
5320 pub object_type: ObjectType,
5321 pub name: BString,
5322}
5323
5324pub fn resolve_rev_path<R: ObjectReader>(
5333 git_dir: &Path,
5334 format: sley_core::ObjectFormat,
5335 reader: &R,
5336 rev: &str,
5337 path: &str,
5338) -> Result<ObjectId> {
5339 resolve_rev_path_entry(git_dir, format, reader, rev, path).map(|entry| entry.oid)
5340}
5341
5342pub fn resolve_rev_path_entry<R: ObjectReader>(
5343 git_dir: &Path,
5344 format: ObjectFormat,
5345 reader: &R,
5346 rev: &str,
5347 path: &str,
5348) -> Result<ResolvedTreePath> {
5349 let rev_oid = resolve_revision_inner(
5353 git_dir,
5354 format,
5355 reader,
5356 rev,
5357 None,
5358 ObjectDisambiguation::Treeish,
5359 )?;
5360 let tree_oid = peel_to_tree(reader, format, &rev_oid)?;
5361 resolve_tree_path_entry(reader, format, &tree_oid, path)
5362 .ok_or_else(|| GitError::not_found(format!("path '{path}' does not exist in '{rev}'")))
5363}
5364
5365pub fn resolve_tree_path_entry<R: ObjectReader>(
5369 reader: &R,
5370 format: ObjectFormat,
5371 tree_oid: &ObjectId,
5372 path: &str,
5373) -> Option<ResolvedTreePath> {
5374 let mut current = *tree_oid;
5375 let components = normalize_treeish_path_components(path);
5376 if components.is_empty() {
5377 return Some(ResolvedTreePath {
5378 oid: current,
5379 mode: None,
5380 object_type: ObjectType::Tree,
5381 name: BString::default(),
5382 });
5383 }
5384 let last = components.len() - 1;
5385 for (idx, component) in components.iter().enumerate() {
5386 let object = reader.read_object(¤t).ok()?;
5387 if object.object_type != ObjectType::Tree {
5388 return None;
5390 }
5391 let mut found = None;
5392 for entry in TreeEntries::new(format, &object.body) {
5393 let entry = entry.ok()?;
5394 if found.is_none() && entry.name == component.as_bytes() {
5395 found = Some((entry.mode, entry.oid, entry.name.into()));
5396 }
5397 }
5398 let (mode, oid, name) = found?;
5399 let object_type = sley_object::tree_entry_object_type(mode);
5400 if idx == last {
5401 return Some(ResolvedTreePath {
5402 oid,
5403 mode: Some(mode),
5404 object_type,
5405 name,
5406 });
5407 }
5408 if object_type != ObjectType::Tree {
5410 return None;
5411 }
5412 current = oid;
5413 }
5414 None
5415}
5416
5417fn normalize_treeish_path(path: &str) -> String {
5418 normalize_treeish_path_components(path).join("/")
5419}
5420
5421fn normalize_treeish_path_components(path: &str) -> Vec<&str> {
5422 path.split('/')
5426 .filter(|part| !part.is_empty() && *part != ".")
5427 .collect()
5428}
5429
5430#[derive(Debug, Clone, PartialEq, Eq)]
5433pub enum SymlinkedTreePath {
5434 Found(ObjectId),
5437 OutOfRepo(Vec<u8>),
5442 Missing,
5445 Dangling,
5448 Loop,
5451 NotDir,
5454}
5455
5456pub const FOLLOW_SYMLINKS_MAX_LINKS: u32 = 40;
5459
5460pub fn resolve_rev_path_follow_symlinks<R: ObjectReader>(
5466 git_dir: &Path,
5467 format: ObjectFormat,
5468 reader: &R,
5469 rev: &str,
5470 path: &str,
5471) -> SymlinkedTreePath {
5472 let Ok(rev_oid) = resolve_revision_with_reader(git_dir, format, reader, rev) else {
5473 return SymlinkedTreePath::Missing;
5474 };
5475 resolve_tree_path_follow_symlinks(reader, format, &rev_oid, path)
5476}
5477
5478pub fn resolve_tree_path_follow_symlinks<R: ObjectReader>(
5485 reader: &R,
5486 format: ObjectFormat,
5487 treeish: &ObjectId,
5488 path: &str,
5489) -> SymlinkedTreePath {
5490 let mut parents: Vec<(ObjectId, Arc<EncodedObject>)> = Vec::new();
5493 let mut namebuf: Vec<u8> = path.as_bytes().to_vec();
5494 let mut current_oid = *treeish;
5495 let mut follows_remaining = FOLLOW_SYMLINKS_MAX_LINKS;
5496 let mut followed_symlink = false;
5499 let mut need_load = true;
5500
5501 loop {
5502 let fail = if followed_symlink {
5503 SymlinkedTreePath::Dangling
5504 } else {
5505 SymlinkedTreePath::Missing
5506 };
5507
5508 if need_load {
5509 let Ok(tree_oid) = peel_to_tree(reader, format, ¤t_oid) else {
5510 return fail;
5511 };
5512 let Ok(object) = reader.read_object(&tree_oid) else {
5513 return fail;
5514 };
5515 if object.object_type != ObjectType::Tree {
5516 return fail;
5517 }
5518 parents.push((tree_oid, object));
5519 if namebuf.is_empty() {
5520 return SymlinkedTreePath::Found(tree_oid);
5523 }
5524 if parents
5525 .last()
5526 .is_some_and(|(_, object)| object.body.is_empty())
5527 {
5528 return fail;
5529 }
5530 need_load = false;
5531 }
5532
5533 while namebuf.first() == Some(&b'/') {
5535 namebuf.remove(0);
5536 }
5537
5538 let slash = namebuf.iter().position(|&byte| byte == b'/');
5540 let (component_len, has_remainder) = match slash {
5541 Some(index) => (index, true),
5542 None => (namebuf.len(), false),
5543 };
5544
5545 if &namebuf[..component_len] == b".." {
5547 if parents.len() == 1 {
5548 return SymlinkedTreePath::OutOfRepo(namebuf);
5551 }
5552 parents.pop();
5553 namebuf.drain(..if has_remainder { 3 } else { 2 });
5554 continue;
5555 }
5556
5557 if component_len == 0 {
5559 let Some((tree_oid, _)) = parents.last() else {
5560 return fail;
5561 };
5562 return SymlinkedTreePath::Found(*tree_oid);
5563 }
5564
5565 let mut found = None;
5567 if let Some((_, object)) = parents.last() {
5568 for entry in TreeEntries::new(format, &object.body) {
5569 let Ok(entry) = entry else {
5570 return fail;
5571 };
5572 if entry.name == &namebuf[..component_len] {
5573 found = Some((entry.mode, entry.oid));
5574 break;
5575 }
5576 }
5577 }
5578 let Some((mode, oid)) = found else {
5579 return fail;
5580 };
5581
5582 match mode & 0o170000 {
5583 0o040000 => {
5584 if !has_remainder {
5586 return SymlinkedTreePath::Found(oid);
5587 }
5588 current_oid = oid;
5589 need_load = true;
5590 namebuf.drain(..component_len + 1);
5591 }
5592 0o100000 => {
5593 if !has_remainder {
5596 return SymlinkedTreePath::Found(oid);
5597 }
5598 return SymlinkedTreePath::NotDir;
5599 }
5600 0o120000 => {
5601 if follows_remaining == 0 {
5603 return SymlinkedTreePath::Loop;
5604 }
5605 follows_remaining -= 1;
5606 followed_symlink = true;
5607 let Ok(link) = reader.read_object(&oid) else {
5608 return SymlinkedTreePath::Dangling;
5609 };
5610 let target = link.body.clone();
5611 if target.first() == Some(&b'/') {
5612 return SymlinkedTreePath::OutOfRepo(target);
5615 }
5616 let mut spliced = target;
5619 if has_remainder {
5620 spliced.push(b'/');
5621 spliced.extend_from_slice(&namebuf[component_len + 1..]);
5622 }
5623 namebuf = spliced;
5624 }
5625 _ => {
5626 return fail;
5630 }
5631 }
5632 }
5633}
5634
5635pub fn split_rev_path_spec(rev: &str) -> Option<(&str, &str)> {
5642 split_rev_path(rev)
5643}
5644
5645fn split_rev_path(rev: &str) -> Option<(&str, &str)> {
5646 RevisionSpecRef::parse(rev).ok()?.tree_path()
5647}
5648
5649fn split_top_level_rev_path(rev: &str) -> Option<(&str, &str)> {
5650 let bytes = rev.as_bytes();
5651 let mut braced_selector_depth = 0usize;
5652 for (index, byte) in bytes.iter().copied().enumerate() {
5653 match byte {
5654 b'{' if index > 0 && matches!(bytes[index - 1], b'^' | b'@') => {
5655 braced_selector_depth = braced_selector_depth.saturating_add(1);
5656 }
5657 b'}' if braced_selector_depth > 0 => {
5658 braced_selector_depth -= 1;
5659 }
5660 b':' if braced_selector_depth == 0 && index > 0 => {
5661 return Some((&rev[..index], &rev[index + 1..]));
5662 }
5663 _ => {}
5664 }
5665 }
5666 None
5667}
5668
5669fn parse_index_stage_path(rest: &str) -> (u8, &str) {
5679 let bytes = rest.as_bytes();
5680 if bytes.len() >= 2 && bytes[1] == b':' && matches!(bytes[0], b'0'..=b'3') {
5681 return (bytes[0] - b'0', &rest[2..]);
5682 }
5683 (0, rest)
5684}
5685
5686fn resolve_index_path<R: ObjectReader>(
5690 git_dir: &Path,
5691 format: sley_core::ObjectFormat,
5692 reader: &R,
5693 stage: u8,
5694 path: &str,
5695) -> Result<ObjectId> {
5696 let normalized_path = normalize_treeish_path(path);
5697 let index_path = repository_index_path(git_dir);
5698 let bytes = match fs::read(&index_path) {
5699 Ok(bytes) => bytes,
5700 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
5701 return Err(GitError::not_found(format!(
5702 "path '{path}' is not in the index"
5703 )));
5704 }
5705 Err(err) => return Err(GitError::Io(err.to_string())),
5706 };
5707 let index = Index::parse(&bytes, format)?;
5708 let mut path_exists = false;
5709 for entry in &index.entries {
5710 if entry.path != normalized_path.as_bytes() {
5711 continue;
5712 }
5713 path_exists = true;
5714 if index_entry_stage(entry) == stage {
5715 return Ok(entry.oid);
5716 }
5717 }
5718 if stage == 0
5719 && let Some(oid) =
5720 resolve_index_path_in_sparse_dir(&index, reader, format, &normalized_path)
5721 {
5722 return Ok(oid);
5723 }
5724 if path_exists {
5725 Err(GitError::not_found(format!(
5726 "path '{path}' is in the index, but not at stage {stage}"
5727 )))
5728 } else {
5729 Err(GitError::not_found(format!(
5730 "path '{path}' is not in the index"
5731 )))
5732 }
5733}
5734
5735fn resolve_index_path_in_sparse_dir<R: ObjectReader>(
5736 index: &Index,
5737 reader: &R,
5738 format: ObjectFormat,
5739 normalized_path: &str,
5740) -> Option<ObjectId> {
5741 for entry in &index.entries {
5742 if !entry.is_sparse_dir() {
5743 continue;
5744 }
5745 let Ok(sparse_dir) = std::str::from_utf8(entry.path.as_bytes()) else {
5746 continue;
5747 };
5748 let Some(remainder) = normalized_path.strip_prefix(sparse_dir) else {
5749 continue;
5750 };
5751 if remainder.is_empty() {
5752 continue;
5753 }
5754 let Some(resolved) = resolve_tree_path_entry(reader, format, &entry.oid, remainder) else {
5755 continue;
5756 };
5757 if resolved.object_type == ObjectType::Tree {
5758 continue;
5759 }
5760 sley_core::trace2::region("index", "ensure_full_index");
5761 return Some(resolved.oid);
5762 }
5763 None
5764}
5765
5766fn index_entry_stage(entry: &sley_index::IndexEntry) -> u8 {
5768 ((entry.flags >> 12) & 0x3) as u8
5769}
5770
5771fn repository_index_path(git_dir: &Path) -> PathBuf {
5773 std::env::var_os("GIT_INDEX_FILE")
5774 .map(PathBuf::from)
5775 .unwrap_or_else(|| git_dir.join("index"))
5776}
5777
5778fn search_commit_message_all<R: ObjectReader>(
5797 git_dir: &Path,
5798 format: sley_core::ObjectFormat,
5799 reader: &R,
5800 text: &str,
5801) -> Result<ObjectId> {
5802 let starts = all_ref_commit_starts(git_dir, format, reader)?;
5803 let mut graph = CommitGraphContext::load(git_dir, format);
5804 let mut seen = HashSet::new();
5805 let mut pending: VecDeque<ObjectId> = starts.into_iter().collect();
5806 let mut best: Option<(i64, ObjectId)> = None;
5807 while let Some(oid) = pending.pop_front() {
5808 if !seen.insert(oid) {
5809 continue;
5810 }
5811 let object = read_revision_object(reader, &oid)?;
5812 if object.object_type != ObjectType::Commit {
5813 return Err(GitError::InvalidObject(format!(
5814 "expected commit {oid}, found {}",
5815 object.object_type.as_str()
5816 )));
5817 }
5818 let commit = Commit::parse_ref(format, &object.body)?;
5819 pending.extend(commit.parents.iter().cloned());
5820 if commit_message_contains(commit.message, text) {
5821 let when = graph
5822 .commit_time(&oid)?
5823 .or_else(|| commit_committer_time(commit.committer))
5824 .unwrap_or(i64::MIN);
5825 if best
5826 .as_ref()
5827 .is_none_or(|(best_when, _)| when >= *best_when)
5828 {
5829 best = Some((when, oid));
5830 }
5831 }
5832 }
5833 best.map(|(_, oid)| oid)
5834 .ok_or_else(|| GitError::not_found(format!("no commit matching ':/{text}'")))
5835}
5836
5837fn search_commit_message_first_parent<R: ObjectReader>(
5840 git_dir: &Path,
5841 reader: &R,
5842 format: sley_core::ObjectFormat,
5843 base: &ObjectId,
5844 text: &str,
5845) -> Result<ObjectId> {
5846 let start = peel_to_commit(reader, format, base)?;
5847 let mut graph = CommitGraphContext::load(git_dir, format);
5851 let mut current = Some(start);
5852 let mut seen = HashSet::new();
5853 while let Some(oid) = current {
5854 if !seen.insert(oid) {
5855 break;
5856 }
5857 let object = read_revision_object(reader, &oid)?;
5858 if object.object_type != ObjectType::Commit {
5859 return Err(GitError::InvalidObject(format!(
5860 "expected commit {oid}, found {}",
5861 object.object_type.as_str()
5862 )));
5863 }
5864 let commit = Commit::parse_ref(format, &object.body)?;
5865 if commit_message_contains(commit.message, text) {
5866 return Ok(oid);
5867 }
5868 current = if reader.is_shallow_graft(&oid) {
5869 None
5870 } else {
5871 match graph.first_parent(&oid)? {
5872 Some(parent) => parent,
5873 None => commit.parents.into_iter().next(),
5874 }
5875 };
5876 }
5877 Err(GitError::not_found(format!(
5878 "no commit matching '^{{/{text}}}' in first-parent history"
5879 )))
5880}
5881
5882fn commit_message_contains(message: &[u8], text: &str) -> bool {
5883 if text.is_empty() {
5884 return true;
5885 }
5886 message
5888 .windows(text.len())
5889 .any(|window| window == text.as_bytes())
5890}
5891
5892fn commit_committer_time(committer: &[u8]) -> Option<i64> {
5895 let line = std::str::from_utf8(committer).ok()?;
5896 let mut fields = line.rsplit(' ');
5899 let _tz = fields.next()?;
5900 fields.next()?.parse::<i64>().ok()
5901}
5902
5903fn all_ref_commit_starts<R: ObjectReader>(
5906 git_dir: &Path,
5907 format: sley_core::ObjectFormat,
5908 reader: &R,
5909) -> Result<Vec<ObjectId>> {
5910 let refs = FileRefStore::new(git_dir.to_path_buf(), format);
5911 let mut starts = Vec::new();
5912 let mut seen = HashSet::new();
5913 for reference in refs.list_refs()? {
5914 let oid = match reference.target {
5915 RefTarget::Direct(oid) => oid,
5916 RefTarget::Symbolic(_) => continue,
5917 };
5918 let Ok(commit) = peel_to_commit(reader, format, &oid) else {
5920 continue;
5921 };
5922 if seen.insert(commit) {
5923 starts.push(commit);
5924 }
5925 }
5926 Ok(starts)
5927}
5928
5929#[derive(Debug, Clone, PartialEq, Eq)]
5935pub enum RevisionRange {
5936 Asymmetric { start: String, end: String },
5938 Symmetric { left: String, right: String },
5941}
5942
5943pub fn parse_revision_range(spec: &str) -> Option<RevisionRange> {
5952 if spec.starts_with(':') {
5953 return None;
5954 }
5955 if let Some(range) = parse_parent_revision_range(spec) {
5956 return Some(range);
5957 }
5958 if let Some((left, right)) = spec.split_once("...") {
5959 if range_operator_is_inside_tree_path(spec, left.len()) {
5960 return None;
5961 }
5962 if left.contains("..") || right.contains("..") {
5963 return None;
5964 }
5965 return Some(RevisionRange::Symmetric {
5966 left: default_range_side(left).to_string(),
5967 right: default_range_side(right).to_string(),
5968 });
5969 }
5970 if let Some((left, right)) = spec.split_once("..") {
5971 if left.is_empty() && right.is_empty() {
5972 return None;
5973 }
5974 if range_operator_is_inside_tree_path(spec, left.len()) {
5975 return None;
5976 }
5977 if left.contains("..") || right.contains("..") {
5978 return None;
5979 }
5980 return Some(RevisionRange::Asymmetric {
5981 start: default_range_side(left).to_string(),
5982 end: default_range_side(right).to_string(),
5983 });
5984 }
5985 None
5986}
5987
5988fn parse_parent_revision_range(spec: &str) -> Option<RevisionRange> {
5989 let (base, parent) = spec.rsplit_once("^-")?;
5990 if range_operator_is_inside_tree_path(spec, base.len()) {
5991 return None;
5992 }
5993 if base.is_empty() {
5994 return None;
5995 }
5996 let parent = if parent.is_empty() {
5997 1
5998 } else if parent.bytes().all(|byte| byte.is_ascii_digit()) {
5999 parent.parse::<usize>().ok()?
6000 } else {
6001 return None;
6002 };
6003 if parent == 0 {
6004 return None;
6005 }
6006 Some(RevisionRange::Asymmetric {
6007 start: format!("{base}^{parent}"),
6008 end: base.to_string(),
6009 })
6010}
6011
6012fn range_operator_is_inside_tree_path(spec: &str, operator_pos: usize) -> bool {
6013 top_level_tree_path_colon(spec).is_some_and(|colon| colon < operator_pos)
6014}
6015
6016fn top_level_tree_path_colon(spec: &str) -> Option<usize> {
6017 let bytes = spec.as_bytes();
6018 let mut braced_selector_depth = 0usize;
6019 for (index, byte) in bytes.iter().copied().enumerate() {
6020 match byte {
6021 b'{' if index > 0 && matches!(bytes[index - 1], b'^' | b'@') => {
6022 braced_selector_depth = braced_selector_depth.saturating_add(1);
6023 }
6024 b'}' if braced_selector_depth > 0 => {
6025 braced_selector_depth -= 1;
6026 }
6027 b':' if braced_selector_depth == 0 && index > 0 => return Some(index),
6028 _ => {}
6029 }
6030 }
6031 None
6032}
6033
6034fn default_range_side(side: &str) -> &str {
6035 if side.is_empty() { "HEAD" } else { side }
6036}
6037
6038#[derive(Debug, Clone, PartialEq, Eq, Default)]
6044pub struct RevisionSelection {
6045 items: Vec<RevisionSelectionItem>,
6046}
6047
6048#[derive(Debug, Clone, PartialEq, Eq)]
6050pub enum RevisionSelectionItem {
6051 Include(String),
6053 Exclude(String),
6055 Range(RevisionRange),
6057}
6058
6059#[derive(Debug, Clone, PartialEq, Eq, Default)]
6065pub struct ResolvedRevisionSelection {
6066 pub starts: Vec<ObjectId>,
6067 pub excluded: HashSet<ObjectId>,
6068}
6069
6070impl RevisionSelection {
6071 pub fn new() -> Self {
6072 Self::default()
6073 }
6074
6075 pub fn from_specs<I, S>(specs: I) -> Result<Self>
6076 where
6077 I: IntoIterator<Item = S>,
6078 S: AsRef<str>,
6079 {
6080 let mut selection = Self::new();
6081 for spec in specs {
6082 selection.add_spec(spec.as_ref())?;
6083 }
6084 Ok(selection)
6085 }
6086
6087 pub fn is_empty(&self) -> bool {
6088 self.items.is_empty()
6089 }
6090
6091 pub fn items(&self) -> &[RevisionSelectionItem] {
6092 &self.items
6093 }
6094
6095 pub fn add_spec(&mut self, spec: impl AsRef<str>) -> Result<&mut Self> {
6096 let spec = spec.as_ref();
6097 if spec.is_empty() {
6098 return Err(GitError::InvalidFormat("empty revision spec".into()));
6099 }
6100 if let Some(rev) = spec.strip_prefix('^') {
6101 if rev.is_empty() {
6102 return Err(GitError::InvalidFormat("empty exclude revision".into()));
6103 }
6104 return self.exclude(rev.to_string());
6105 }
6106 if let Some(range) = parse_revision_range(spec) {
6107 self.range(range);
6108 return Ok(self);
6109 }
6110 self.include(spec.to_string())
6111 }
6112
6113 pub fn include(&mut self, rev: impl Into<String>) -> Result<&mut Self> {
6114 let rev = RevisionSpec::parse(rev)?.raw;
6115 self.items.push(RevisionSelectionItem::Include(rev));
6116 Ok(self)
6117 }
6118
6119 pub fn exclude(&mut self, rev: impl Into<String>) -> Result<&mut Self> {
6120 let rev = RevisionSpec::parse(rev)?.raw;
6121 self.items.push(RevisionSelectionItem::Exclude(rev));
6122 Ok(self)
6123 }
6124
6125 pub fn range(&mut self, range: RevisionRange) -> &mut Self {
6126 self.items.push(RevisionSelectionItem::Range(range));
6127 self
6128 }
6129
6130 pub fn resolve<R: ObjectReader>(
6131 &self,
6132 git_dir: &Path,
6133 format: sley_core::ObjectFormat,
6134 reader: &R,
6135 ) -> Result<ResolvedRevisionSelection> {
6136 let mut resolved = ResolvedRevisionSelection::default();
6137 for item in &self.items {
6138 match item {
6139 RevisionSelectionItem::Include(rev) => {
6140 resolved
6141 .starts
6142 .push(resolve_range_endpoint(git_dir, format, reader, rev)?);
6143 }
6144 RevisionSelectionItem::Exclude(rev) => {
6145 let oid = resolve_range_endpoint(git_dir, format, reader, rev)?;
6146 extend_excluded_ancestors(
6147 git_dir,
6148 format,
6149 reader,
6150 &mut resolved.excluded,
6151 &oid,
6152 )?;
6153 }
6154 RevisionSelectionItem::Range(range) => {
6155 resolve_selection_range(git_dir, format, reader, range, &mut resolved)?;
6156 }
6157 }
6158 }
6159 Ok(resolved)
6160 }
6161}
6162
6163impl ResolvedRevisionSelection {
6164 pub fn selected_commit_oids<R: ObjectReader>(
6167 &self,
6168 git_dir: &Path,
6169 format: sley_core::ObjectFormat,
6170 reader: &R,
6171 first_parent: bool,
6172 ) -> Result<Vec<ObjectId>> {
6173 let mut graph = CommitGraphContext::load(git_dir, format);
6174 let mut seen = HashSet::new();
6175 let mut pending: VecDeque<ObjectId> = self.starts.clone().into();
6176 let mut out = Vec::new();
6177 while let Some(oid) = pending.pop_front() {
6178 if !seen.insert(oid) || self.excluded.contains(&oid) {
6179 continue;
6180 }
6181 if first_parent {
6182 pending.extend(graph.commit_first_parent(reader, &oid)?);
6183 out.push(oid);
6184 continue;
6185 }
6186 for parent in graph.commit_parent_ids(reader, &oid)? {
6187 pending.push_back(parent);
6188 }
6189 out.push(oid);
6190 }
6191 Ok(out)
6192 }
6193}
6194
6195pub fn resolve_revision_range<R: ObjectReader>(
6202 git_dir: &Path,
6203 format: sley_core::ObjectFormat,
6204 reader: &R,
6205 range: &RevisionRange,
6206) -> Result<Vec<ObjectId>> {
6207 match range {
6208 RevisionRange::Asymmetric { start, end } => {
6209 let start_oid = resolve_range_endpoint(git_dir, format, reader, start)?;
6210 let end_oid = resolve_range_endpoint(git_dir, format, reader, end)?;
6211 let excluded = ancestor_set(git_dir, reader, format, &start_oid)?;
6212 let included = ancestor_set(git_dir, reader, format, &end_oid)?;
6213 Ok(included
6214 .into_iter()
6215 .filter(|oid| !excluded.contains(oid))
6216 .collect())
6217 }
6218 RevisionRange::Symmetric { left, right } => {
6219 let left_oid = resolve_range_endpoint(git_dir, format, reader, left)?;
6220 let right_oid = resolve_range_endpoint(git_dir, format, reader, right)?;
6221 let left_set = ancestor_set(git_dir, reader, format, &left_oid)?;
6222 let right_set = ancestor_set(git_dir, reader, format, &right_oid)?;
6223 let mut out = Vec::new();
6224 for oid in &left_set {
6225 if !right_set.contains(oid) {
6226 out.push(*oid);
6227 }
6228 }
6229 for oid in &right_set {
6230 if !left_set.contains(oid) {
6231 out.push(*oid);
6232 }
6233 }
6234 Ok(out)
6235 }
6236 }
6237}
6238
6239fn resolve_selection_range<R: ObjectReader>(
6240 git_dir: &Path,
6241 format: sley_core::ObjectFormat,
6242 reader: &R,
6243 range: &RevisionRange,
6244 resolved: &mut ResolvedRevisionSelection,
6245) -> Result<()> {
6246 match range {
6247 RevisionRange::Asymmetric { start, end } => {
6248 let start_oid = resolve_range_endpoint(git_dir, format, reader, start)?;
6249 let end_oid = resolve_range_endpoint(git_dir, format, reader, end)?;
6250 extend_excluded_ancestors(git_dir, format, reader, &mut resolved.excluded, &start_oid)?;
6251 resolved.starts.push(end_oid);
6252 }
6253 RevisionRange::Symmetric { left, right } => {
6254 let left_oid = resolve_range_endpoint(git_dir, format, reader, left)?;
6255 let right_oid = resolve_range_endpoint(git_dir, format, reader, right)?;
6256 resolved.starts.push(left_oid);
6257 resolved.starts.push(right_oid);
6258 for base in merge_bases(git_dir, format, reader, &left_oid, &right_oid)? {
6259 extend_excluded_ancestors(git_dir, format, reader, &mut resolved.excluded, &base)?;
6260 }
6261 }
6262 }
6263 Ok(())
6264}
6265
6266fn resolve_range_endpoint<R: ObjectReader>(
6267 git_dir: &Path,
6268 format: sley_core::ObjectFormat,
6269 reader: &R,
6270 rev: &str,
6271) -> Result<ObjectId> {
6272 let oid = resolve_revision_with_reader(git_dir, format, reader, rev)?;
6273 peel_to_commit(reader, format, &oid)
6274}
6275
6276fn extend_excluded_ancestors<R: ObjectReader>(
6277 git_dir: &Path,
6278 format: sley_core::ObjectFormat,
6279 reader: &R,
6280 excluded: &mut HashSet<ObjectId>,
6281 start: &ObjectId,
6282) -> Result<()> {
6283 excluded.extend(ancestor_set(git_dir, reader, format, start)?);
6284 Ok(())
6285}
6286
6287fn ancestor_set<R: ObjectReader>(
6290 git_dir: &Path,
6291 reader: &R,
6292 format: sley_core::ObjectFormat,
6293 start: &ObjectId,
6294) -> Result<HashSet<ObjectId>> {
6295 let mut graph = CommitGraphContext::load(git_dir, format);
6296 ancestor_set_with_graph(&mut graph, reader, start)
6297}
6298
6299fn ancestor_set_with_graph<R: ObjectReader>(
6305 graph: &mut CommitGraphContext<'_>,
6306 reader: &R,
6307 start: &ObjectId,
6308) -> Result<HashSet<ObjectId>> {
6309 let mut seen = HashSet::new();
6310 let mut pending = VecDeque::from([*start]);
6311 while let Some(oid) = pending.pop_front() {
6312 if !seen.insert(oid) {
6313 continue;
6314 }
6315 for parent in graph.commit_parents(reader, &oid)? {
6316 pending.push_back(parent);
6317 }
6318 }
6319 Ok(seen)
6320}
6321
6322pub fn ahead_behind_counts<R: ObjectReader>(
6331 git_dir: &Path,
6332 format: sley_core::ObjectFormat,
6333 reader: &R,
6334 local: &ObjectId,
6335 target: &ObjectId,
6336) -> Result<(usize, usize)> {
6337 if local == target {
6338 return Ok((0, 0));
6339 }
6340
6341 let mut graph = CommitGraphContext::load(git_dir, format);
6342 if let Some(ahead) = linear_unique_count(&mut graph, reader, local, target)? {
6343 return Ok((ahead, 0));
6344 }
6345 if let Some(behind) = linear_unique_count(&mut graph, reader, target, local)? {
6346 return Ok((0, behind));
6347 }
6348
6349 let local_reachable = ancestor_set_with_graph(&mut graph, reader, local)?;
6350 let target_reachable = ancestor_set_with_graph(&mut graph, reader, target)?;
6351 let ahead = local_reachable.difference(&target_reachable).count();
6352 let behind = target_reachable.difference(&local_reachable).count();
6353 Ok((ahead, behind))
6354}
6355
6356fn linear_unique_count<R: ObjectReader>(
6357 graph: &mut CommitGraphContext<'_>,
6358 reader: &R,
6359 descendant: &ObjectId,
6360 ancestor: &ObjectId,
6361) -> Result<Option<usize>> {
6362 let mut current = *descendant;
6363 let mut count = 0usize;
6364 let mut seen = HashSet::new();
6365 loop {
6366 if ¤t == ancestor {
6367 return Ok(Some(count));
6368 }
6369 if !seen.insert(current) {
6370 return Ok(None);
6371 }
6372
6373 let mut parents = graph.commit_parent_ids(reader, ¤t)?;
6374 let Some(parent) = parents.next() else {
6375 return Ok(None);
6376 };
6377 if parents.next().is_some() {
6378 return Ok(None);
6379 }
6380 count += 1;
6381 current = parent;
6382 }
6383}
6384
6385pub fn is_ancestor<R: ObjectReader>(
6388 git_dir: &Path,
6389 format: sley_core::ObjectFormat,
6390 reader: &R,
6391 ancestor: &ObjectId,
6392 descendant: &ObjectId,
6393) -> Result<bool> {
6394 if ancestor == descendant {
6395 return Ok(true);
6396 }
6397 let mut graph = CommitGraphContext::load(git_dir, format);
6398
6399 let min_generation = graph.generation(ancestor)?;
6405 if let (Some(anc_gen), Some(desc_gen)) = (min_generation, graph.generation(descendant)?)
6406 && anc_gen >= desc_gen
6407 {
6408 return Ok(false);
6409 }
6410
6411 let mut seen = HashSet::new();
6412 let mut pending = VecDeque::from([*descendant]);
6413 while let Some(oid) = pending.pop_front() {
6414 if !seen.insert(oid) {
6415 continue;
6416 }
6417 if let (Some(floor), Some(here)) = (min_generation, graph.generation(&oid)?)
6422 && here < floor
6423 {
6424 continue;
6425 }
6426 for parent in graph.commit_parents(reader, &oid)? {
6427 if &parent == ancestor {
6428 return Ok(true);
6429 }
6430 pending.push_back(parent);
6431 }
6432 }
6433 Ok(false)
6434}
6435
6436pub fn merge_bases<R: ObjectReader>(
6440 git_dir: &Path,
6441 format: sley_core::ObjectFormat,
6442 reader: &R,
6443 left: &ObjectId,
6444 right: &ObjectId,
6445) -> Result<Vec<ObjectId>> {
6446 let mut graph = CommitGraphContext::load(git_dir, format);
6453 let left_depths = ancestor_depths_with_graph(&mut graph, reader, left)?;
6454 let right_depths = ancestor_depths_with_graph(&mut graph, reader, right)?;
6455 let candidates: Vec<ObjectId> = left_depths
6456 .keys()
6457 .filter(|oid| right_depths.contains_key(*oid))
6458 .cloned()
6459 .collect();
6460 let candidate_set: HashSet<ObjectId> = candidates.iter().copied().collect();
6464 let mut dominated = HashSet::new();
6465 for candidate in &candidates {
6466 for parent in graph.commit_parents(reader, candidate)? {
6467 if candidate_set.contains(&parent) {
6468 dominated.insert(parent);
6469 }
6470 }
6471 }
6472 let mut bases: Vec<ObjectId> = candidates
6473 .into_iter()
6474 .filter(|candidate| !dominated.contains(candidate))
6475 .collect();
6476 bases.sort_by_key(|oid| oid.to_hex());
6477 Ok(bases)
6478}
6479
6480fn ancestor_depths_with_graph<R: ObjectReader>(
6487 graph: &mut CommitGraphContext<'_>,
6488 reader: &R,
6489 start: &ObjectId,
6490) -> Result<HashMap<ObjectId, usize>> {
6491 let mut depths = HashMap::new();
6492 let mut pending = VecDeque::from([(*start, 0usize)]);
6493 while let Some((oid, depth)) = pending.pop_front() {
6494 if depths.get(&oid).is_some_and(|existing| *existing <= depth) {
6495 continue;
6496 }
6497 depths.insert(oid, depth);
6498 for parent in graph.commit_parents(reader, &oid)? {
6499 pending.push_back((parent, depth + 1));
6500 }
6501 }
6502 Ok(depths)
6503}
6504
6505#[cfg(test)]
6506mod tests {
6507 use super::*;
6508 use sley_core::ObjectFormat;
6509 use sley_object::EncodedObject;
6510 use sley_odb::{ObjectDatabase, ObjectWriter};
6511 use sley_refs::{RefTarget, RefUpdate, ReflogEntry};
6512 use std::cell::Cell;
6513 use std::fs;
6514 use std::sync::atomic::{AtomicU64, Ordering};
6515
6516 static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
6517
6518 #[test]
6519 fn setup_revisions_parses_ranges_carets_and_not() {
6520 let fixture = setup_revisions_fixture();
6521 let setup = run_setup(&fixture, ["base..main", "^side", "--not", "base", "^main"])
6522 .expect("setup should parse");
6523 assert_eq!(
6524 setup
6525 .options
6526 .positives
6527 .iter()
6528 .map(|tip| tip.oid)
6529 .collect::<Vec<_>>(),
6530 vec![fixture.tip, fixture.tip]
6531 );
6532 assert_oid_set(
6533 setup.options.negatives,
6534 [fixture.base, fixture.side, fixture.base],
6535 );
6536 }
6537
6538 #[test]
6539 fn setup_revisions_parses_symmetric_difference() {
6540 let fixture = setup_revisions_fixture();
6541 let setup = run_setup(&fixture, ["left...right"]).expect("setup should parse");
6542 assert_oid_set(
6543 setup.options.positives.iter().map(|tip| tip.oid),
6544 [fixture.left, fixture.right],
6545 );
6546 assert_eq!(setup.options.negatives, vec![fixture.base]);
6547 assert_eq!(
6548 setup.options.symmetric_ranges,
6549 vec![RevisionSymmetricRange {
6550 left: fixture.left,
6551 right: fixture.right,
6552 negated: false,
6553 }]
6554 );
6555 }
6556
6557 #[test]
6558 fn setup_revisions_parses_parent_shorthand_range() {
6559 let git_dir = temp_git_dir();
6560 let worktree = git_dir.with_extension("worktree");
6561 fs::create_dir_all(&worktree).expect("test operation should succeed");
6562 let mut db = FileObjectDatabase::from_git_dir(&git_dir, ObjectFormat::Sha1);
6563 let tree = write_tree(&mut db, &[]);
6564 let base = write_test_commit(&mut db, tree, Vec::new(), b"base\n");
6565 let first = write_test_commit(&mut db, tree, vec![base], b"first\n");
6566 let second = write_test_commit(&mut db, tree, vec![base], b"second\n");
6567 let merge = write_test_commit(&mut db, tree, vec![first, second], b"merge\n");
6568 set_branch(&git_dir, "merge", &merge);
6569
6570 let args = ["merge^-2".to_string()];
6571 let setup = setup_revisions(
6572 &args,
6573 &RevisionSetupContext {
6574 git_dir: &git_dir,
6575 worktree_root: Some(&worktree),
6576 cwd: &worktree,
6577 format: ObjectFormat::Sha1,
6578 reader: &db,
6579 config: None,
6580 },
6581 )
6582 .expect("setup should parse parent shorthand range");
6583 assert_eq!(
6584 setup
6585 .options
6586 .positives
6587 .iter()
6588 .map(|tip| tip.oid)
6589 .collect::<Vec<_>>(),
6590 vec![merge]
6591 );
6592 assert_eq!(setup.options.negatives, vec![second]);
6593 fs::remove_dir_all(git_dir).expect("test operation should succeed");
6594 fs::remove_dir_all(worktree).expect("test operation should succeed");
6595 }
6596
6597 #[test]
6598 fn setup_revisions_expands_all_with_scoped_exclude() {
6599 let fixture = setup_revisions_fixture();
6600 let setup =
6604 run_setup(&fixture, ["--exclude=skip/*", "--branches"]).expect("setup should parse");
6605 assert_oid_set(
6606 setup.options.positives.iter().map(|tip| tip.oid),
6607 [
6608 fixture.tip,
6609 fixture.left,
6610 fixture.right,
6611 fixture.base,
6612 fixture.side,
6613 ],
6614 );
6615 assert!(
6616 !setup
6617 .options
6618 .positives
6619 .iter()
6620 .any(|tip| tip.oid == fixture.skipped)
6621 );
6622 }
6623
6624 #[test]
6625 fn setup_revisions_collects_pathspecs_after_boundary() {
6626 let fixture = setup_revisions_fixture();
6627 let setup =
6628 run_setup(&fixture, ["HEAD", "--", "missing-path"]).expect("setup should parse");
6629 assert_eq!(setup.options.positives[0].oid, fixture.tip);
6630 assert_eq!(setup.pathspecs, vec!["missing-path".to_string()]);
6631 }
6632
6633 #[test]
6634 fn setup_revisions_reports_ambiguous_argument() {
6635 let fixture = setup_revisions_fixture();
6636 let err = run_setup(&fixture, ["not-a-rev-or-path"]).expect_err("setup should fail");
6637 assert!(matches!(err, GitError::Exit(128)));
6638 assert_eq!(
6639 ambiguous_argument_message("not-a-rev-or-path"),
6640 "fatal: ambiguous argument 'not-a-rev-or-path': unknown revision or path not in the working tree.\nUse '--' to separate paths from revisions, like this:\n'git <command> [<revision>...] -- [<file>...]'"
6641 );
6642 }
6643
6644 #[test]
6645 fn walk_commits_missing_start_reports_revision_walk_context() {
6646 let db = ObjectDatabase::new(ObjectFormat::Sha1);
6647 let missing = ObjectId::from_hex(
6648 ObjectFormat::Sha1,
6649 "1111111111111111111111111111111111111111",
6650 )
6651 .expect("test operation should succeed");
6652
6653 let err = walk_commits(&db, ObjectFormat::Sha1, [missing])
6654 .expect_err("missing commit should error");
6655 let kind = err.not_found_kind().expect("typed not found");
6656 assert_eq!(kind.object_id(), Some(missing));
6657 assert_eq!(
6658 kind.missing_object_context(),
6659 Some(MissingObjectContext::RevisionWalk)
6660 );
6661 }
6662
6663 #[test]
6664 fn resolve_revision_reads_symbolic_head_and_tags() {
6665 let git_dir = temp_git_dir();
6666 let oid = ObjectId::from_hex(
6667 ObjectFormat::Sha1,
6668 "ce013625030ba8dba906f756967f9e9ca394464a",
6669 )
6670 .expect("test operation should succeed");
6671 fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n")
6672 .expect("test operation should succeed");
6673 let refs = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
6674 let mut tx = refs.transaction();
6675 tx.update(RefUpdate {
6676 name: "refs/heads/main".into(),
6677 expected: None,
6678 new: RefTarget::Direct(oid),
6679 reflog: None,
6680 });
6681 tx.update(RefUpdate {
6682 name: "refs/tags/v1.0".into(),
6683 expected: None,
6684 new: RefTarget::Direct(oid),
6685 reflog: None,
6686 });
6687 tx.commit().expect("test operation should succeed");
6688 assert_eq!(
6689 resolve_revision(&git_dir, ObjectFormat::Sha1, "HEAD")
6690 .expect("test operation should succeed"),
6691 oid
6692 );
6693 assert_eq!(
6694 resolve_revision(&git_dir, ObjectFormat::Sha1, "v1.0")
6695 .expect("test operation should succeed"),
6696 oid
6697 );
6698 fs::remove_dir_all(git_dir).expect("test operation should succeed");
6699 }
6700
6701 #[test]
6702 fn resolve_revision_supports_parent_suffixes() {
6703 let git_dir = temp_git_dir();
6704 let mut db = ObjectDatabase::new(ObjectFormat::Sha1);
6705 let tree = ObjectId::from_hex(
6706 ObjectFormat::Sha1,
6707 "4b825dc642cb6eb9a060e54bf8d69288fbee4904",
6708 )
6709 .expect("test operation should succeed");
6710 let base = write_test_commit(&mut db, tree, Vec::new(), b"base\n");
6711 let first_parent = write_test_commit(&mut db, tree, vec![base], b"main\n");
6712 let second_parent = write_test_commit(&mut db, tree, vec![base], b"side\n");
6713 let merge = write_test_commit(&mut db, tree, vec![first_parent, second_parent], b"merge\n");
6714 assert_eq!(
6715 resolve_revision_with_reader(&git_dir, ObjectFormat::Sha1, &db, &format!("{merge}^"))
6716 .expect("test operation should succeed"),
6717 first_parent
6718 );
6719 assert_eq!(
6720 resolve_revision_with_reader(&git_dir, ObjectFormat::Sha1, &db, &format!("{merge}^2"))
6721 .expect("test operation should succeed"),
6722 second_parent
6723 );
6724 assert_eq!(
6725 resolve_revision_with_reader(&git_dir, ObjectFormat::Sha1, &db, &format!("{merge}~2"))
6726 .expect("test operation should succeed"),
6727 base
6728 );
6729 fs::remove_dir_all(git_dir).expect("test operation should succeed");
6730 }
6731
6732 #[test]
6733 fn resolve_revision_parent_suffix_honors_commit_grafts() {
6734 let git_dir = temp_git_dir();
6735 fs::create_dir_all(git_dir.join("info")).expect("test operation should succeed");
6736 let mut db = FileObjectDatabase::from_git_dir(&git_dir, ObjectFormat::Sha1);
6737 let tree = ObjectId::from_hex(
6738 ObjectFormat::Sha1,
6739 "4b825dc642cb6eb9a060e54bf8d69288fbee4904",
6740 )
6741 .expect("test operation should succeed");
6742 let root = write_test_commit(&mut db, tree, Vec::new(), b"root\n");
6743 let first = write_test_commit(&mut db, tree, vec![root], b"first\n");
6744 let second = write_test_commit(&mut db, tree, vec![root], b"second\n");
6745 let third = write_test_commit(&mut db, tree, vec![root], b"third\n");
6746 fs::write(
6747 git_dir.join("info").join("grafts"),
6748 format!("{first} {second} {third}\n"),
6749 )
6750 .expect("test operation should succeed");
6751
6752 assert_eq!(
6753 resolve_revision_with_reader(&git_dir, ObjectFormat::Sha1, &db, &format!("{first}^2"))
6754 .expect("test operation should succeed"),
6755 third
6756 );
6757 fs::remove_dir_all(git_dir).expect("test operation should succeed");
6758 }
6759
6760 #[test]
6761 fn resolve_revision_supports_abbreviated_loose_object_ids() {
6762 let git_dir = temp_git_dir();
6763 let db = FileObjectDatabase::from_git_dir(&git_dir, ObjectFormat::Sha1);
6764 let oid = db
6765 .write_object(EncodedObject::new(ObjectType::Blob, b"abbrev\n".to_vec()))
6766 .expect("test operation should succeed");
6767
6768 assert_eq!(
6769 resolve_revision(&git_dir, ObjectFormat::Sha1, &oid.to_hex()[..8])
6770 .expect("test operation should succeed"),
6771 oid
6772 );
6773 fs::remove_dir_all(git_dir).expect("test operation should succeed");
6774 }
6775
6776 #[test]
6777 fn resolve_revision_prefers_ref_over_abbreviated_object_id() {
6778 let git_dir = temp_git_dir();
6779 let db = FileObjectDatabase::from_git_dir(&git_dir, ObjectFormat::Sha1);
6780 let object = db
6781 .write_object(EncodedObject::new(
6782 ObjectType::Blob,
6783 b"abbrev conflict\n".to_vec(),
6784 ))
6785 .expect("test operation should succeed");
6786 let target = ObjectId::from_hex(
6787 ObjectFormat::Sha1,
6788 "1111111111111111111111111111111111111111",
6789 )
6790 .expect("test operation should succeed");
6791 let refs = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
6792 let mut tx = refs.transaction();
6793 tx.update(RefUpdate {
6794 name: format!("refs/heads/{}", &object.to_hex()[..4]),
6795 expected: None,
6796 new: RefTarget::Direct(target),
6797 reflog: None,
6798 });
6799 tx.commit().expect("test operation should succeed");
6800
6801 assert_eq!(
6802 resolve_revision(&git_dir, ObjectFormat::Sha1, &object.to_hex()[..4])
6803 .expect("test operation should succeed"),
6804 target
6805 );
6806 fs::remove_dir_all(git_dir).expect("test operation should succeed");
6807 }
6808
6809 #[test]
6810 fn resolve_revision_uses_commit_graph_for_parent_suffixes() {
6811 let git_dir = temp_git_dir();
6812 fs::create_dir_all(git_dir.join("objects").join("info"))
6813 .expect("test operation should succeed");
6814 let parent = ObjectId::from_hex(
6815 ObjectFormat::Sha1,
6816 "1111111111111111111111111111111111111111",
6817 )
6818 .expect("test operation should succeed");
6819 let child = ObjectId::from_hex(
6820 ObjectFormat::Sha1,
6821 "2222222222222222222222222222222222222222",
6822 )
6823 .expect("test operation should succeed");
6824 fs::write(git_dir.join("HEAD"), format!("{child}\n"))
6825 .expect("test operation should succeed");
6826 fs::write(
6827 git_dir.join("objects").join("info").join("commit-graph"),
6828 test_commit_graph(ObjectFormat::Sha1, &parent, &child),
6829 )
6830 .expect("test operation should succeed");
6831
6832 struct MissingReader;
6833 impl ObjectReader for MissingReader {
6834 fn read_object(&self, oid: &ObjectId) -> Result<std::sync::Arc<EncodedObject>> {
6835 Err(GitError::not_found(format!(
6836 "object reader should not be used for {oid}"
6837 )))
6838 }
6839 }
6840
6841 assert_eq!(
6842 resolve_revision_with_reader(&git_dir, ObjectFormat::Sha1, &MissingReader, "HEAD^",)
6843 .expect("test operation should succeed"),
6844 parent
6845 );
6846 fs::remove_dir_all(git_dir).expect("test operation should succeed");
6847 }
6848
6849 #[test]
6850 fn peel_to_tree_handles_commits_and_tags() {
6851 let mut db = ObjectDatabase::new(ObjectFormat::Sha1);
6852 let tree = ObjectId::from_hex(
6853 ObjectFormat::Sha1,
6854 "4b825dc642cb6eb9a060e54bf8d69288fbee4904",
6855 )
6856 .expect("test operation should succeed");
6857 db.write_object(EncodedObject::new(ObjectType::Tree, Vec::new()))
6858 .expect("test operation should succeed");
6859 let commit = write_test_commit(&mut db, tree, Vec::new(), b"base\n");
6860 let tag = Tag {
6861 object: commit,
6862 object_type: ObjectType::Commit,
6863 name: b"v1.0".to_vec(),
6864 tagger: Some(b"Example User <example@example.invalid> 0 +0000".to_vec()),
6865 message: b"release\n".to_vec(),
6866 raw_body: None,
6867 };
6868 let tag = db
6869 .write_object(EncodedObject::new(ObjectType::Tag, tag.write()))
6870 .expect("test operation should succeed");
6871 assert_eq!(
6872 peel_to_tree(&db, ObjectFormat::Sha1, &commit).expect("test operation should succeed"),
6873 tree
6874 );
6875 assert_eq!(
6876 peel_to_tree(&db, ObjectFormat::Sha1, &tag).expect("test operation should succeed"),
6877 tree
6878 );
6879 }
6880
6881 #[test]
6882 fn peel_to_commit_handles_annotated_tags() {
6883 let mut db = ObjectDatabase::new(ObjectFormat::Sha1);
6884 let tree = ObjectId::from_hex(
6885 ObjectFormat::Sha1,
6886 "4b825dc642cb6eb9a060e54bf8d69288fbee4904",
6887 )
6888 .expect("test operation should succeed");
6889 db.write_object(EncodedObject::new(ObjectType::Tree, Vec::new()))
6890 .expect("test operation should succeed");
6891 let commit = write_test_commit(&mut db, tree, Vec::new(), b"base\n");
6892 let tag = Tag {
6893 object: commit,
6894 object_type: ObjectType::Commit,
6895 name: b"v1.0".to_vec(),
6896 tagger: Some(b"Example User <example@example.invalid> 0 +0000".to_vec()),
6897 message: b"release\n".to_vec(),
6898 raw_body: None,
6899 };
6900 let tag = db
6901 .write_object(EncodedObject::new(ObjectType::Tag, tag.write()))
6902 .expect("test operation should succeed");
6903 assert_eq!(
6904 peel_to_commit(&db, ObjectFormat::Sha1, &tag).expect("test operation should succeed"),
6905 commit
6906 );
6907 }
6908
6909 #[test]
6910 fn resolve_revision_supports_peel_suffixes() {
6911 let git_dir = temp_git_dir();
6912 let mut db = ObjectDatabase::new(ObjectFormat::Sha1);
6913 let tree = ObjectId::from_hex(
6914 ObjectFormat::Sha1,
6915 "4b825dc642cb6eb9a060e54bf8d69288fbee4904",
6916 )
6917 .expect("test operation should succeed");
6918 db.write_object(EncodedObject::new(ObjectType::Tree, Vec::new()))
6919 .expect("test operation should succeed");
6920 let commit = write_test_commit(&mut db, tree, Vec::new(), b"base\n");
6921 let tag = Tag {
6922 object: commit,
6923 object_type: ObjectType::Commit,
6924 name: b"v1.0".to_vec(),
6925 tagger: Some(b"Example User <example@example.invalid> 0 +0000".to_vec()),
6926 message: b"release\n".to_vec(),
6927 raw_body: None,
6928 };
6929 let tag = db
6930 .write_object(EncodedObject::new(ObjectType::Tag, tag.write()))
6931 .expect("test operation should succeed");
6932 assert_eq!(
6933 resolve_revision_with_reader(&git_dir, ObjectFormat::Sha1, &db, &format!("{tag}^{{}}"))
6934 .expect("test operation should succeed"),
6935 commit
6936 );
6937 assert_eq!(
6938 resolve_revision_with_reader(
6939 &git_dir,
6940 ObjectFormat::Sha1,
6941 &db,
6942 &format!("{tag}^{{commit}}")
6943 )
6944 .expect("test operation should succeed"),
6945 commit
6946 );
6947 assert_eq!(
6948 resolve_revision_with_reader(
6949 &git_dir,
6950 ObjectFormat::Sha1,
6951 &db,
6952 &format!("{tag}^{{tree}}")
6953 )
6954 .expect("test operation should succeed"),
6955 tree
6956 );
6957 assert_eq!(
6958 resolve_revision_with_reader(
6959 &git_dir,
6960 ObjectFormat::Sha1,
6961 &db,
6962 &format!("{tag}^{{tag}}")
6963 )
6964 .expect("test operation should succeed"),
6965 tag
6966 );
6967 fs::remove_dir_all(git_dir).expect("test operation should succeed");
6968 }
6969
6970 #[test]
6971 fn pack_refs_with_auto_peel_writes_peeled_tag() {
6972 let git_dir = temp_git_dir();
6973 let db = FileObjectDatabase::from_git_dir(&git_dir, ObjectFormat::Sha1);
6974 let tree = db
6975 .write_object(EncodedObject::new(ObjectType::Tree, Vec::new()))
6976 .expect("test operation should succeed");
6977 let commit = Commit {
6978 tree,
6979 parents: Vec::new(),
6980 author: b"Example User <example@example.invalid> 0 +0000".to_vec(),
6981 committer: b"Example User <example@example.invalid> 0 +0000".to_vec(),
6982 encoding: None,
6983 message: b"base\n".to_vec(),
6984 };
6985 let commit = db
6986 .write_object(EncodedObject::new(ObjectType::Commit, commit.write()))
6987 .expect("test operation should succeed");
6988 let tag = Tag {
6989 object: commit,
6990 object_type: ObjectType::Commit,
6991 name: b"v1.0".to_vec(),
6992 tagger: Some(b"Example User <example@example.invalid> 0 +0000".to_vec()),
6993 message: b"release\n".to_vec(),
6994 raw_body: None,
6995 };
6996 let tag = db
6997 .write_object(EncodedObject::new(ObjectType::Tag, tag.write()))
6998 .expect("test operation should succeed");
6999 let refs = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
7000 let mut tx = refs.transaction();
7001 tx.update(RefUpdate {
7002 name: "refs/tags/v1.0".into(),
7003 expected: None,
7004 new: RefTarget::Direct(tag),
7005 reflog: None,
7006 });
7007 tx.commit().expect("test operation should succeed");
7008
7009 let packed = pack_refs_with_auto_peel(&git_dir, ObjectFormat::Sha1, true)
7010 .expect("test operation should succeed");
7011 let packed_tag = packed
7012 .iter()
7013 .find(|packed| packed.reference.name == "refs/tags/v1.0")
7014 .expect("test operation should succeed");
7015 assert_eq!(packed_tag.peeled, Some(commit));
7016 assert_eq!(
7017 refs.read_ref("refs/tags/v1.0")
7018 .expect("test operation should succeed"),
7019 Some(RefTarget::Direct(tag))
7020 );
7021 assert!(!git_dir.join("refs").join("tags").join("v1.0").exists());
7022 fs::remove_dir_all(git_dir).expect("test operation should succeed");
7023 }
7024
7025 #[test]
7026 fn resolve_rev_path_finds_nested_blob_and_subtree() {
7027 let git_dir = temp_git_dir();
7028 let mut db = ObjectDatabase::new(ObjectFormat::Sha1);
7029 let blob = db
7030 .write_object(EncodedObject::new(ObjectType::Blob, b"hello\n".to_vec()))
7031 .expect("test operation should succeed");
7032 let sub = write_tree(&mut db, &[(0o100644, b"file.txt", &blob)]);
7033 let dir = write_tree(&mut db, &[(0o040000, b"sub", &sub)]);
7034 let root = write_tree(&mut db, &[(0o040000, b"dir", &dir)]);
7035 let commit = write_test_commit(&mut db, root, Vec::new(), b"init\n");
7036
7037 assert_eq!(
7039 resolve_rev_path(
7040 &git_dir,
7041 ObjectFormat::Sha1,
7042 &db,
7043 &commit.to_hex(),
7044 "dir/sub/file.txt"
7045 )
7046 .expect("test operation should succeed"),
7047 blob
7048 );
7049 assert_eq!(
7050 resolve_rev_path(
7051 &git_dir,
7052 ObjectFormat::Sha1,
7053 &db,
7054 &commit.to_hex(),
7055 "./dir/./sub/file.txt"
7056 )
7057 .expect("test operation should succeed"),
7058 blob
7059 );
7060 assert_eq!(
7062 resolve_rev_path(
7063 &git_dir,
7064 ObjectFormat::Sha1,
7065 &db,
7066 &commit.to_hex(),
7067 "dir/sub"
7068 )
7069 .expect("test operation should succeed"),
7070 sub
7071 );
7072 assert_eq!(
7074 resolve_rev_path(&git_dir, ObjectFormat::Sha1, &db, &commit.to_hex(), "")
7075 .expect("test operation should succeed"),
7076 root
7077 );
7078 let entry = resolve_rev_path_entry(
7079 &git_dir,
7080 ObjectFormat::Sha1,
7081 &db,
7082 &commit.to_hex(),
7083 "dir/sub/file.txt",
7084 )
7085 .expect("test operation should succeed");
7086 assert_eq!(entry.oid, blob);
7087 assert_eq!(entry.mode, Some(0o100644));
7088 assert_eq!(entry.object_type, ObjectType::Blob);
7089 assert_eq!(entry.name, b"file.txt");
7090 let entry = resolve_rev_path_entry(&git_dir, ObjectFormat::Sha1, &db, &commit.to_hex(), "")
7091 .expect("test operation should succeed");
7092 assert_eq!(entry.oid, root);
7093 assert_eq!(entry.mode, None);
7094 assert_eq!(entry.object_type, ObjectType::Tree);
7095 assert!(entry.name.is_empty());
7096 assert_eq!(
7098 resolve_revision_with_reader(
7099 &git_dir,
7100 ObjectFormat::Sha1,
7101 &db,
7102 &format!("{commit}:dir/sub/file.txt"),
7103 )
7104 .expect("test operation should succeed"),
7105 blob
7106 );
7107 fs::remove_dir_all(git_dir).expect("test operation should succeed");
7108 }
7109
7110 #[test]
7111 fn resolve_rev_path_reports_missing_and_non_tree_paths() {
7112 let git_dir = temp_git_dir();
7113 let mut db = ObjectDatabase::new(ObjectFormat::Sha1);
7114 let blob = db
7115 .write_object(EncodedObject::new(ObjectType::Blob, b"root\n".to_vec()))
7116 .expect("test operation should succeed");
7117 let root = write_tree(&mut db, &[(0o100644, b"root.txt", &blob)]);
7118 let commit = write_test_commit(&mut db, root, Vec::new(), b"init\n");
7119
7120 let missing = resolve_rev_path(
7122 &git_dir,
7123 ObjectFormat::Sha1,
7124 &db,
7125 &commit.to_hex(),
7126 "nope.txt",
7127 )
7128 .expect_err("test operation should fail");
7129 assert!(
7130 matches!(&missing, GitError::NotFound(kind) if kind.to_string().contains("does not exist")),
7131 "unexpected error: {missing:?}"
7132 );
7133
7134 let not_tree = resolve_rev_path(
7136 &git_dir,
7137 ObjectFormat::Sha1,
7138 &db,
7139 &commit.to_hex(),
7140 "root.txt/x",
7141 )
7142 .expect_err("test operation should fail");
7143 assert!(
7144 matches!(¬_tree, GitError::NotFound(kind) if kind.to_string().contains("does not exist")),
7145 "unexpected error: {not_tree:?}"
7146 );
7147 fs::remove_dir_all(git_dir).expect("test operation should succeed");
7148 }
7149
7150 #[test]
7151 fn resolve_index_path_reads_stage_entries() {
7152 let git_dir = temp_git_dir();
7153 let oid_zero = ObjectId::from_hex(
7154 ObjectFormat::Sha1,
7155 "1111111111111111111111111111111111111111",
7156 )
7157 .expect("test operation should succeed");
7158 let oid_two = ObjectId::from_hex(
7159 ObjectFormat::Sha1,
7160 "2222222222222222222222222222222222222222",
7161 )
7162 .expect("test operation should succeed");
7163 let index = Index {
7164 version: 2,
7165 entries: vec![
7166 test_index_entry(b"file.txt", &oid_zero, 0),
7167 test_index_entry(b"conflict.txt", &oid_two, 2),
7168 ],
7169 extensions: Vec::new(),
7170 checksum: None,
7171 };
7172 fs::write(
7173 git_dir.join("index"),
7174 index
7175 .write(ObjectFormat::Sha1)
7176 .expect("test operation should succeed"),
7177 )
7178 .expect("test operation should succeed");
7179
7180 assert_eq!(
7182 resolve_revision_with_reader(
7183 &git_dir,
7184 ObjectFormat::Sha1,
7185 &ObjectDatabase::new(ObjectFormat::Sha1),
7186 ":file.txt",
7187 )
7188 .expect("test operation should succeed"),
7189 oid_zero
7190 );
7191 assert_eq!(
7192 resolve_revision_with_reader(
7193 &git_dir,
7194 ObjectFormat::Sha1,
7195 &ObjectDatabase::new(ObjectFormat::Sha1),
7196 ":./file.txt",
7197 )
7198 .expect("test operation should succeed"),
7199 oid_zero
7200 );
7201 assert_eq!(
7203 resolve_revision_with_reader(
7204 &git_dir,
7205 ObjectFormat::Sha1,
7206 &ObjectDatabase::new(ObjectFormat::Sha1),
7207 ":2:conflict.txt",
7208 )
7209 .expect("test operation should succeed"),
7210 oid_two
7211 );
7212 let wrong_stage = resolve_revision_with_reader(
7214 &git_dir,
7215 ObjectFormat::Sha1,
7216 &ObjectDatabase::new(ObjectFormat::Sha1),
7217 ":1:conflict.txt",
7218 )
7219 .expect_err("test operation should fail");
7220 assert!(
7221 matches!(&wrong_stage, GitError::NotFound(kind) if kind.to_string().contains("not at stage 1")),
7222 "unexpected error: {wrong_stage:?}"
7223 );
7224 let unknown = resolve_revision_with_reader(
7226 &git_dir,
7227 ObjectFormat::Sha1,
7228 &ObjectDatabase::new(ObjectFormat::Sha1),
7229 ":missing.txt",
7230 )
7231 .expect_err("test operation should fail");
7232 assert!(
7233 matches!(&unknown, GitError::NotFound(kind) if kind.to_string().contains("not in the index")),
7234 "unexpected error: {unknown:?}"
7235 );
7236 fs::remove_dir_all(git_dir).expect("test operation should succeed");
7237 }
7238
7239 #[test]
7240 fn resolve_index_path_reads_blobs_beneath_sparse_directory_entries() {
7241 let git_dir = temp_git_dir();
7242 let mut db = ObjectDatabase::new(ObjectFormat::Sha1);
7243 let blob = db
7244 .write_object(EncodedObject::new(ObjectType::Blob, b"sparse\n".to_vec()))
7245 .expect("test operation should succeed");
7246 let nested = write_tree(&mut db, &[]);
7247 let sparse_tree = write_tree(
7248 &mut db,
7249 &[(0o100644, b"a", &blob), (0o040000, b"nested", &nested)],
7250 );
7251 let mut sparse_dir = test_index_entry(b"folder1/", &sparse_tree, 0);
7252 sparse_dir.mode = sley_index::SPARSE_DIR_MODE;
7253 sparse_dir.set_skip_worktree(true);
7254 let index = Index {
7255 version: 3,
7256 entries: vec![sparse_dir],
7257 extensions: Vec::new(),
7258 checksum: None,
7259 };
7260 fs::write(
7261 git_dir.join("index"),
7262 index
7263 .write(ObjectFormat::Sha1)
7264 .expect("test operation should succeed"),
7265 )
7266 .expect("test operation should succeed");
7267
7268 assert_eq!(
7269 resolve_revision_with_reader(&git_dir, ObjectFormat::Sha1, &db, ":folder1/a")
7270 .expect("test operation should succeed"),
7271 blob
7272 );
7273 for spec in [":folder1/", ":folder1/nested/"] {
7274 let err = resolve_revision_with_reader(&git_dir, ObjectFormat::Sha1, &db, spec)
7275 .expect_err("test operation should fail");
7276 assert!(
7277 matches!(&err, GitError::NotFound(kind) if kind.to_string().contains("not in the index")),
7278 "unexpected error for {spec}: {err:?}"
7279 );
7280 }
7281 fs::remove_dir_all(git_dir).expect("test operation should succeed");
7282 }
7283
7284 #[test]
7285 fn search_commit_message_all_finds_matching_commit() {
7286 let git_dir = temp_git_dir();
7287 let mut db = FileObjectDatabase::from_git_dir(&git_dir, ObjectFormat::Sha1);
7288 let tree = db
7289 .write_object(EncodedObject::new(ObjectType::Tree, Vec::new()))
7290 .expect("test operation should succeed");
7291 let first = write_dated_commit(&mut db, tree, Vec::new(), b"add feature\n", 1000);
7292 let second = write_dated_commit(&mut db, tree, vec![first], b"fix the widget bug\n", 2000);
7293 let third = write_dated_commit(&mut db, tree, vec![second], b"unrelated change\n", 3000);
7294 let refs = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
7295 let mut tx = refs.transaction();
7296 tx.update(RefUpdate {
7297 name: "refs/heads/main".into(),
7298 expected: None,
7299 new: RefTarget::Direct(third),
7300 reflog: None,
7301 });
7302 tx.commit().expect("test operation should succeed");
7303
7304 assert_eq!(
7305 resolve_revision_with_reader(&git_dir, ObjectFormat::Sha1, &db, ":/widget bug")
7306 .expect("test operation should succeed"),
7307 second
7308 );
7309 assert_eq!(
7311 resolve_revision_with_reader(
7312 &git_dir,
7313 ObjectFormat::Sha1,
7314 &db,
7315 &format!("{third}^{{/widget bug}}"),
7316 )
7317 .expect("test operation should succeed"),
7318 second
7319 );
7320 let miss = resolve_revision_with_reader(&git_dir, ObjectFormat::Sha1, &db, ":/zzznomatch")
7322 .expect_err("test operation should fail");
7323 assert!(
7324 matches!(miss, GitError::NotFound(_)),
7325 "unexpected: {miss:?}"
7326 );
7327 fs::remove_dir_all(git_dir).expect("test operation should succeed");
7328 }
7329
7330 #[test]
7331 fn revision_spec_ref_splits_only_top_level_tree_path_colons() {
7332 assert_eq!(
7333 RevisionSpecRef::parse("HEAD:hello")
7334 .expect("test operation should succeed")
7335 .tree_path(),
7336 Some(("HEAD", "hello"))
7337 );
7338 assert_eq!(
7339 RevisionSpecRef::parse("HEAD^{/testing:}:hello")
7340 .expect("test operation should succeed")
7341 .tree_path(),
7342 Some(("HEAD^{/testing:}", "hello"))
7343 );
7344 assert_eq!(
7345 RevisionSpecRef::parse("HEAD@{2024-01-01 10:00:00}:hello")
7346 .expect("test operation should succeed")
7347 .tree_path(),
7348 Some(("HEAD@{2024-01-01 10:00:00}", "hello"))
7349 );
7350 assert_eq!(
7351 RevisionSpecRef::parse(":/testing: message")
7352 .expect("test operation should succeed")
7353 .kind(),
7354 RevisionSpecKind::MessageSearch {
7355 text: "testing: message"
7356 }
7357 );
7358 }
7359
7360 #[test]
7361 fn read_bisect_terms_defaults_and_matches_custom_refs() {
7362 let git_dir = temp_git_dir();
7363 let terms = read_bisect_terms(&git_dir).expect("test operation should succeed");
7364 assert_eq!(terms, BisectTerms::default());
7365 assert!(terms.is_bad_ref("refs/bisect/bad"));
7366 assert!(terms.is_good_ref("refs/bisect/good-1234"));
7367
7368 fs::write(git_dir.join("BISECT_TERMS"), b"curious\nknown\n")
7369 .expect("test operation should succeed");
7370 let terms = read_bisect_terms(&git_dir).expect("test operation should succeed");
7371 assert_eq!(terms.bad, "curious");
7372 assert_eq!(terms.good, "known");
7373 assert!(terms.is_bad_ref("refs/bisect/curious-1"));
7374 assert!(terms.is_good_ref("refs/bisect/known-3"));
7375 assert!(!terms.is_bad_ref("refs/bisect/bad"));
7376 assert!(!terms.is_good_ref("refs/bisect/good"));
7377
7378 fs::remove_dir_all(git_dir).expect("test operation should succeed");
7379 }
7380
7381 #[test]
7382 fn resolve_rev_path_after_commit_message_search_suffix() {
7383 let git_dir = temp_git_dir();
7384 let mut db = FileObjectDatabase::from_git_dir(&git_dir, ObjectFormat::Sha1);
7385 let blob = db
7386 .write_object(EncodedObject::new(ObjectType::Blob, b"hello\n".to_vec()))
7387 .expect("test operation should succeed");
7388 let tree = write_tree(&mut db, &[(0o100644, b"hello", &blob)]);
7389 let base = write_dated_commit(&mut db, tree, Vec::new(), b"base\n", 1000);
7390 let searched =
7391 write_dated_commit(&mut db, tree, vec![base], b"testing: path search\n", 2000);
7392 let tip = write_dated_commit(&mut db, tree, vec![searched], b"tip\n", 3000);
7393 set_branch(&git_dir, "other", &tip);
7394
7395 assert_eq!(
7396 resolve_revision_with_reader(
7397 &git_dir,
7398 ObjectFormat::Sha1,
7399 &db,
7400 "other^{/testing:}:hello",
7401 )
7402 .expect("test operation should succeed"),
7403 blob
7404 );
7405 fs::remove_dir_all(git_dir).expect("test operation should succeed");
7406 }
7407
7408 #[test]
7409 fn parse_revision_range_recognizes_dot_forms() {
7410 assert_eq!(
7411 parse_revision_range("a..b"),
7412 Some(RevisionRange::Asymmetric {
7413 start: "a".into(),
7414 end: "b".into(),
7415 })
7416 );
7417 assert_eq!(
7418 parse_revision_range("a...b"),
7419 Some(RevisionRange::Symmetric {
7420 left: "a".into(),
7421 right: "b".into(),
7422 })
7423 );
7424 assert_eq!(
7425 parse_revision_range("..b"),
7426 Some(RevisionRange::Asymmetric {
7427 start: "HEAD".into(),
7428 end: "b".into(),
7429 })
7430 );
7431 assert_eq!(
7432 parse_revision_range("a.."),
7433 Some(RevisionRange::Asymmetric {
7434 start: "a".into(),
7435 end: "HEAD".into(),
7436 })
7437 );
7438 assert_eq!(
7439 parse_revision_range("merge^-"),
7440 Some(RevisionRange::Asymmetric {
7441 start: "merge^1".into(),
7442 end: "merge".into(),
7443 })
7444 );
7445 assert_eq!(
7446 parse_revision_range("merge^-2"),
7447 Some(RevisionRange::Asymmetric {
7448 start: "merge^2".into(),
7449 end: "merge".into(),
7450 })
7451 );
7452 assert_eq!(parse_revision_range("merge^-0"), None);
7453 assert_eq!(parse_revision_range("merge^-2x"), None);
7454 assert_eq!(parse_revision_range("plain"), None);
7455 assert_eq!(parse_revision_range(".."), None);
7456 assert_eq!(parse_revision_range(":../file.txt"), None);
7457 assert_eq!(parse_revision_range(":/message..text"), None);
7458 assert_eq!(parse_revision_range("HEAD:../top"), None);
7459 assert_eq!(parse_revision_range("HEAD:path..with-dots"), None);
7460 assert_eq!(parse_revision_range("HEAD:path...with-dots"), None);
7461 assert_eq!(parse_revision_range("HEAD:path^-"), None);
7462 }
7463
7464 #[test]
7465 fn resolve_revision_range_excludes_ancestors_and_symmetric_difference() {
7466 let git_dir = temp_git_dir();
7467 let mut db = ObjectDatabase::new(ObjectFormat::Sha1);
7468 let tree = ObjectId::from_hex(
7469 ObjectFormat::Sha1,
7470 "4b825dc642cb6eb9a060e54bf8d69288fbee4904",
7471 )
7472 .expect("test operation should succeed");
7473 let base = write_test_commit(&mut db, tree, Vec::new(), b"base\n");
7476 let a = write_test_commit(&mut db, tree, vec![base], b"a\n");
7477 let b = write_test_commit(&mut db, tree, vec![a], b"b\n");
7478 let c = write_test_commit(&mut db, tree, vec![base], b"c\n");
7479 let d = write_test_commit(&mut db, tree, vec![c.clone()], b"d\n");
7480
7481 let range = RevisionRange::Asymmetric {
7484 start: a.to_hex(),
7485 end: b.to_hex(),
7486 };
7487 let mut got = resolve_revision_range(&git_dir, ObjectFormat::Sha1, &db, &range)
7488 .expect("test operation should succeed");
7489 got.sort_by_key(|x| x.to_hex());
7490 assert_eq!(got, vec![b]);
7491 assert!(!got.contains(&a), "A itself is excluded");
7492 assert!(!got.contains(&base), "A's ancestors are excluded");
7493
7494 let sym = RevisionRange::Symmetric {
7497 left: b.to_hex(),
7498 right: d.to_hex(),
7499 };
7500 let got_sym: HashSet<ObjectId> =
7501 resolve_revision_range(&git_dir, ObjectFormat::Sha1, &db, &sym)
7502 .expect("test operation should succeed")
7503 .into_iter()
7504 .collect();
7505 let expected: HashSet<ObjectId> = [a, b, c, d].into_iter().collect();
7506 assert_eq!(got_sym, expected);
7507 assert!(!got_sym.contains(&base), "shared base excluded from ...");
7508 fs::remove_dir_all(git_dir).expect("test operation should succeed");
7509 }
7510
7511 #[test]
7512 fn revision_selection_resolves_asymmetric_range() {
7513 let git_dir = temp_git_dir();
7514 let format = ObjectFormat::Sha1;
7515 let (db, all) = build_history(&git_dir, format);
7516 let root = all[0].clone();
7517 let a = all[1].clone();
7518 let c = all[3].clone();
7519
7520 let selection = RevisionSelection::from_specs([format!("{a}..{c}")])
7521 .expect("test operation should succeed");
7522 let resolved = selection
7523 .resolve(&git_dir, format, &db)
7524 .expect("test operation should succeed");
7525
7526 assert_eq!(resolved.starts, vec![c.clone()]);
7527 assert_eq!(resolved.excluded, oid_set([root, a]));
7528 assert_oid_set(
7529 resolved
7530 .selected_commit_oids(&git_dir, format, &db, false)
7531 .expect("test operation should succeed"),
7532 [c],
7533 );
7534 fs::remove_dir_all(git_dir).expect("test operation should succeed");
7535 }
7536
7537 #[test]
7538 fn revision_selection_resolves_default_left_range() {
7539 let git_dir = temp_git_dir();
7540 let format = ObjectFormat::Sha1;
7541 let (db, all) = build_history(&git_dir, format);
7542 let root = all[0].clone();
7543 let a = all[1].clone();
7544 let c = all[3].clone();
7545 fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n")
7546 .expect("test operation should succeed");
7547 set_branch(&git_dir, "main", &a);
7548
7549 let selection = RevisionSelection::from_specs([format!("..{c}")])
7550 .expect("test operation should succeed");
7551 let resolved = selection
7552 .resolve(&git_dir, format, &db)
7553 .expect("test operation should succeed");
7554
7555 assert_eq!(resolved.starts, vec![c.clone()]);
7556 assert_eq!(resolved.excluded, oid_set([root, a]));
7557 assert_oid_set(
7558 resolved
7559 .selected_commit_oids(&git_dir, format, &db, false)
7560 .expect("test operation should succeed"),
7561 [c],
7562 );
7563 fs::remove_dir_all(git_dir).expect("test operation should succeed");
7564 }
7565
7566 #[test]
7567 fn revision_selection_resolves_default_right_range() {
7568 let git_dir = temp_git_dir();
7569 let format = ObjectFormat::Sha1;
7570 let (db, all) = build_history(&git_dir, format);
7571 let root = all[0].clone();
7572 let a = all[1].clone();
7573 let c = all[3].clone();
7574 fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n")
7575 .expect("test operation should succeed");
7576 set_branch(&git_dir, "main", &c);
7577
7578 let selection = RevisionSelection::from_specs([format!("{a}..")])
7579 .expect("test operation should succeed");
7580 let resolved = selection
7581 .resolve(&git_dir, format, &db)
7582 .expect("test operation should succeed");
7583
7584 assert_eq!(resolved.starts, vec![c.clone()]);
7585 assert_eq!(resolved.excluded, oid_set([root, a]));
7586 assert_oid_set(
7587 resolved
7588 .selected_commit_oids(&git_dir, format, &db, false)
7589 .expect("test operation should succeed"),
7590 [c],
7591 );
7592 fs::remove_dir_all(git_dir).expect("test operation should succeed");
7593 }
7594
7595 #[test]
7596 fn revision_selection_resolves_symmetric_range() {
7597 let git_dir = temp_git_dir();
7598 let format = ObjectFormat::Sha1;
7599 let (db, all) = build_history(&git_dir, format);
7600 let root = all[0].clone();
7601 let a = all[1].clone();
7602 let b = all[2].clone();
7603
7604 let selection = RevisionSelection::from_specs([format!("{a}...{b}")])
7605 .expect("test operation should succeed");
7606 let resolved = selection
7607 .resolve(&git_dir, format, &db)
7608 .expect("test operation should succeed");
7609
7610 assert_eq!(resolved.starts, vec![a, b]);
7611 assert_eq!(resolved.excluded, oid_set([root]));
7612 assert_oid_set(
7613 resolved
7614 .selected_commit_oids(&git_dir, format, &db, false)
7615 .expect("test operation should succeed"),
7616 [a, b],
7617 );
7618 fs::remove_dir_all(git_dir).expect("test operation should succeed");
7619 }
7620
7621 #[test]
7622 fn revision_selection_resolves_caret_exclude() {
7623 let git_dir = temp_git_dir();
7624 let format = ObjectFormat::Sha1;
7625 let (db, all) = build_history(&git_dir, format);
7626 let root = all[0].clone();
7627 let a = all[1].clone();
7628
7629 let selection = RevisionSelection::from_specs([format!("^{a}")])
7630 .expect("test operation should succeed");
7631 let resolved = selection
7632 .resolve(&git_dir, format, &db)
7633 .expect("test operation should succeed");
7634
7635 assert!(resolved.starts.is_empty());
7636 assert_eq!(resolved.excluded, oid_set([root, a]));
7637 assert!(
7638 resolved
7639 .selected_commit_oids(&git_dir, format, &db, false)
7640 .expect("test operation should succeed")
7641 .is_empty()
7642 );
7643 fs::remove_dir_all(git_dir).expect("test operation should succeed");
7644 }
7645
7646 #[test]
7647 fn revision_selection_resolves_bare_include() {
7648 let git_dir = temp_git_dir();
7649 let format = ObjectFormat::Sha1;
7650 let (db, all) = build_history(&git_dir, format);
7651 let root = all[0].clone();
7652 let a = all[1].clone();
7653 let c = all[3].clone();
7654
7655 let selection =
7656 RevisionSelection::from_specs([c.to_hex()]).expect("test operation should succeed");
7657 let resolved = selection
7658 .resolve(&git_dir, format, &db)
7659 .expect("test operation should succeed");
7660
7661 assert_eq!(resolved.starts, vec![c.clone()]);
7662 assert!(resolved.excluded.is_empty());
7663 assert_oid_set(
7664 resolved
7665 .selected_commit_oids(&git_dir, format, &db, false)
7666 .expect("test operation should succeed"),
7667 [root, a, c],
7668 );
7669 fs::remove_dir_all(git_dir).expect("test operation should succeed");
7670 }
7671
7672 #[test]
7673 fn merge_bases_finds_common_ancestor() {
7674 let git_dir = temp_git_dir();
7675 let mut db = ObjectDatabase::new(ObjectFormat::Sha1);
7676 let tree = ObjectId::from_hex(
7677 ObjectFormat::Sha1,
7678 "4b825dc642cb6eb9a060e54bf8d69288fbee4904",
7679 )
7680 .expect("test operation should succeed");
7681 let base = write_test_commit(&mut db, tree, Vec::new(), b"base\n");
7682 let left = write_test_commit(&mut db, tree, vec![base], b"left\n");
7683 let right = write_test_commit(&mut db, tree, vec![base], b"right\n");
7684 assert_eq!(
7685 merge_bases(&git_dir, ObjectFormat::Sha1, &db, &left, &right)
7686 .expect("test operation should succeed"),
7687 vec![base]
7688 );
7689 fs::remove_dir_all(git_dir).expect("test operation should succeed");
7690 }
7691
7692 #[test]
7693 fn merge_bases_drop_common_ancestors_of_better_common_ancestors() {
7694 let git_dir = temp_git_dir();
7695 let mut db = ObjectDatabase::new(ObjectFormat::Sha1);
7696 let tree = ObjectId::from_hex(
7697 ObjectFormat::Sha1,
7698 "4b825dc642cb6eb9a060e54bf8d69288fbee4904",
7699 )
7700 .expect("test operation should succeed");
7701
7702 let e = write_test_commit(&mut db, tree, Vec::new(), b"E\n");
7707 let d = write_test_commit(&mut db, tree, vec![e], b"D\n");
7708 let f = write_test_commit(&mut db, tree, vec![e], b"F\n");
7709 let c = write_test_commit(&mut db, tree, vec![d], b"C\n");
7710 let b = write_test_commit(&mut db, tree, vec![c], b"B\n");
7711 let a = write_test_commit(&mut db, tree, vec![b], b"A\n");
7712 let g = write_test_commit(&mut db, tree, vec![b, e], b"G\n");
7713 let h = write_test_commit(&mut db, tree, vec![a, f], b"H\n");
7714
7715 assert_eq!(
7716 merge_bases(&git_dir, ObjectFormat::Sha1, &db, &g, &h)
7717 .expect("test operation should succeed"),
7718 vec![b]
7719 );
7720 fs::remove_dir_all(git_dir).expect("test operation should succeed");
7721 }
7722
7723 #[test]
7724 fn resolve_bare_at_is_head() {
7725 let git_dir = temp_git_dir();
7726 let oid = test_oid(0xaa);
7727 fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n")
7728 .expect("test operation should succeed");
7729 set_branch(&git_dir, "main", &oid);
7730 assert_eq!(
7731 resolve_revision(&git_dir, ObjectFormat::Sha1, "@")
7732 .expect("test operation should succeed"),
7733 oid
7734 );
7735 fs::remove_dir_all(git_dir).expect("test operation should succeed");
7736 }
7737
7738 #[test]
7739 fn resolve_head_reflog_nth() {
7740 let git_dir = temp_git_dir();
7741 let c0 = test_oid(0x10);
7742 let c1 = test_oid(0x11);
7743 let c2 = test_oid(0x12);
7744 fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n")
7745 .expect("test operation should succeed");
7746 set_branch(&git_dir, "main", &c2);
7747 write_head_reflog(
7749 &git_dir,
7750 &[
7751 (&zero_oid(), &c0, "commit (initial): c0"),
7752 (&c0, &c1, "commit: c1"),
7753 (&c1, &c2, "commit: c2"),
7754 ],
7755 );
7756 write_branch_reflog(
7757 &git_dir,
7758 "main",
7759 &[
7760 (&zero_oid(), &c0, "commit (initial): c0"),
7761 (&c0, &c1, "commit: c1"),
7762 (&c1, &c2, "commit: c2"),
7763 ],
7764 );
7765
7766 assert_eq!(
7768 resolve_revision(&git_dir, ObjectFormat::Sha1, "@{0}")
7769 .expect("test operation should succeed"),
7770 c2
7771 );
7772 assert_eq!(
7773 resolve_revision(&git_dir, ObjectFormat::Sha1, "HEAD@{1}")
7774 .expect("test operation should succeed"),
7775 c1
7776 );
7777 assert_eq!(
7778 resolve_revision(&git_dir, ObjectFormat::Sha1, "@{2}")
7779 .expect("test operation should succeed"),
7780 c0
7781 );
7782 let err = resolve_revision(&git_dir, ObjectFormat::Sha1, "@{5}")
7784 .expect_err("test operation should fail");
7785 assert!(
7786 matches!(&err, GitError::NotFound(kind) if kind.to_string().contains("only has 3 entries")),
7787 "unexpected error: {err:?}"
7788 );
7789 fs::remove_dir_all(git_dir).expect("test operation should succeed");
7790 }
7791
7792 #[test]
7793 fn resolve_branch_reflog_nth() {
7794 let git_dir = temp_git_dir();
7795 let old = test_oid(0x20);
7796 let new = test_oid(0x21);
7797 set_branch(&git_dir, "topic", &new);
7798 write_branch_reflog(
7799 &git_dir,
7800 "topic",
7801 &[
7802 (&zero_oid(), &old, "branch: Created"),
7803 (&old, &new, "commit: work"),
7804 ],
7805 );
7806 assert_eq!(
7807 resolve_revision(&git_dir, ObjectFormat::Sha1, "topic@{0}")
7808 .expect("test operation should succeed"),
7809 new
7810 );
7811 assert_eq!(
7812 resolve_revision(&git_dir, ObjectFormat::Sha1, "topic@{1}")
7813 .expect("test operation should succeed"),
7814 old
7815 );
7816 fs::remove_dir_all(git_dir).expect("test operation should succeed");
7817 }
7818
7819 #[test]
7820 fn resolve_upstream_via_branch_config() {
7821 let git_dir = temp_git_dir();
7822 let tip = test_oid(0x30);
7823 fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n")
7824 .expect("test operation should succeed");
7825 set_branch(&git_dir, "main", &tip);
7826 set_ref(&git_dir, "refs/remotes/origin/main", &tip);
7827 fs::write(
7828 git_dir.join("config"),
7829 b"[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n",
7830 )
7831 .expect("test operation should succeed");
7832
7833 for spec in ["@{u}", "@{upstream}", "main@{upstream}"] {
7834 assert_eq!(
7835 resolve_revision(&git_dir, ObjectFormat::Sha1, spec)
7836 .expect("test operation should succeed"),
7837 tip,
7838 "spec {spec}"
7839 );
7840 }
7841 fs::remove_dir_all(git_dir).expect("test operation should succeed");
7842 }
7843
7844 #[test]
7845 fn resolve_push_falls_back_to_upstream_then_uses_push_remote() {
7846 let git_dir = temp_git_dir();
7847 let up = test_oid(0x40);
7848 let pushed = test_oid(0x41);
7849 fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n")
7850 .expect("test operation should succeed");
7851 set_branch(&git_dir, "main", &up);
7852 set_ref(&git_dir, "refs/remotes/origin/main", &up);
7853
7854 fs::write(
7856 git_dir.join("config"),
7857 b"[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n",
7858 )
7859 .expect("test operation should succeed");
7860 assert_eq!(
7861 resolve_revision(&git_dir, ObjectFormat::Sha1, "@{push}")
7862 .expect("test operation should succeed"),
7863 up
7864 );
7865
7866 set_ref(&git_dir, "refs/remotes/fork/main", &pushed);
7874 fs::write(
7875 git_dir.join("config"),
7876 b"[push]\n\tdefault = current\n[branch \"main\"]\n\tremote = origin\n\tpushRemote = fork\n\tmerge = refs/heads/main\n",
7877 )
7878 .expect("test operation should succeed");
7879 assert_eq!(
7880 resolve_revision(&git_dir, ObjectFormat::Sha1, "@{push}")
7881 .expect("test operation should succeed"),
7882 pushed
7883 );
7884 assert_eq!(
7886 resolve_revision(&git_dir, ObjectFormat::Sha1, "@{u}")
7887 .expect("test operation should succeed"),
7888 up
7889 );
7890
7891 fs::write(
7894 git_dir.join("config"),
7895 b"[branch \"main\"]\n\tremote = origin\n\tpushRemote = fork\n\tmerge = refs/heads/main\n",
7896 )
7897 .expect("test operation should succeed");
7898 let err = resolve_revision(&git_dir, ObjectFormat::Sha1, "@{push}")
7899 .expect_err("triangular simple push must not resolve");
7900 assert!(
7901 matches!(&err, GitError::NotFound(kind) if kind.to_string().contains("simple")),
7902 "unexpected error: {err:?}"
7903 );
7904 fs::remove_dir_all(git_dir).expect("test operation should succeed");
7905 }
7906
7907 #[test]
7908 fn resolve_previous_checkout_branch() {
7909 let git_dir = temp_git_dir();
7910 let main_tip = test_oid(0x50);
7911 let feature_tip = test_oid(0x51);
7912 fs::write(git_dir.join("HEAD"), b"ref: refs/heads/feature\n")
7913 .expect("test operation should succeed");
7914 set_branch(&git_dir, "main", &main_tip);
7915 set_branch(&git_dir, "feature", &feature_tip);
7916 write_head_reflog(
7918 &git_dir,
7919 &[
7920 (
7921 &feature_tip,
7922 &feature_tip,
7923 "checkout: moving from main to feature",
7924 ),
7925 (
7926 &feature_tip,
7927 &main_tip,
7928 "checkout: moving from feature to main",
7929 ),
7930 (
7931 &main_tip,
7932 &feature_tip,
7933 "checkout: moving from main to feature",
7934 ),
7935 ],
7936 );
7937 assert_eq!(
7939 resolve_revision(&git_dir, ObjectFormat::Sha1, "@{-1}")
7940 .expect("test operation should succeed"),
7941 main_tip
7942 );
7943 assert_eq!(
7945 resolve_revision(&git_dir, ObjectFormat::Sha1, "@{-2}")
7946 .expect("test operation should succeed"),
7947 feature_tip
7948 );
7949 fs::remove_dir_all(git_dir).expect("test operation should succeed");
7950 }
7951
7952 #[test]
7953 fn empty_base_reflog_uses_current_branch_not_head() {
7954 let git_dir = temp_git_dir();
7955 let old_one = test_oid(0x52);
7956 let old_two = test_oid(0x53);
7957 let new_two = test_oid(0x54);
7958 fs::write(git_dir.join("HEAD"), b"ref: refs/heads/old-branch\n")
7959 .expect("test operation should succeed");
7960 set_branch(&git_dir, "old-branch", &old_two);
7961 write_branch_reflog(
7962 &git_dir,
7963 "old-branch",
7964 &[
7965 (&zero_oid(), &old_one, "commit (initial): old-one"),
7966 (&old_one, &old_two, "commit: old-two"),
7967 ],
7968 );
7969 write_head_reflog(
7970 &git_dir,
7971 &[
7972 (
7973 &old_two,
7974 &new_two,
7975 "checkout: moving from old-branch to new-branch",
7976 ),
7977 (
7978 &new_two,
7979 &old_two,
7980 "checkout: moving from new-branch to old-branch",
7981 ),
7982 ],
7983 );
7984 assert_eq!(
7985 resolve_revision(&git_dir, ObjectFormat::Sha1, "@{1}")
7986 .expect("test operation should succeed"),
7987 old_one
7988 );
7989 assert_eq!(
7990 resolve_revision(&git_dir, ObjectFormat::Sha1, "HEAD@{1}")
7991 .expect("test operation should succeed"),
7992 new_two
7993 );
7994 fs::remove_dir_all(git_dir).expect("test operation should succeed");
7995 }
7996
7997 #[test]
7998 fn reflog_nth_requires_reflog_but_uses_oldest_fallback() {
7999 let git_dir = temp_git_dir();
8000 let base = test_oid(0x55);
8001 let tip = test_oid(0x56);
8002 set_branch(&git_dir, "newbranch", &tip);
8003 assert!(
8004 resolve_revision(&git_dir, ObjectFormat::Sha1, "newbranch@{0}").is_err(),
8005 "branch without reflog must not resolve @{{0}}"
8006 );
8007 write_branch_reflog(&git_dir, "newbranch", &[(&base, &tip, "commit: tip")]);
8008 assert_eq!(
8009 resolve_revision(&git_dir, ObjectFormat::Sha1, "newbranch@{1}")
8010 .expect("test operation should succeed"),
8011 base
8012 );
8013 fs::remove_dir_all(git_dir).expect("test operation should succeed");
8014 }
8015
8016 #[test]
8017 fn prior_checkout_and_head_alias_compose_with_at_marks() {
8018 let git_dir = temp_git_dir();
8019 let main_tip = test_oid(0x57);
8020 let old_one = test_oid(0x58);
8021 let old_two = test_oid(0x59);
8022 let new_tip = test_oid(0x5a);
8023 fs::write(git_dir.join("HEAD"), b"ref: refs/heads/new-branch\n")
8024 .expect("test operation should succeed");
8025 set_branch(&git_dir, "main", &main_tip);
8026 set_branch(&git_dir, "old-branch", &old_two);
8027 set_branch(&git_dir, "new-branch", &new_tip);
8028 write_branch_reflog(
8029 &git_dir,
8030 "old-branch",
8031 &[
8032 (&zero_oid(), &old_one, "commit (initial): old-one"),
8033 (&old_one, &old_two, "commit: old-two"),
8034 ],
8035 );
8036 write_head_reflog(
8037 &git_dir,
8038 &[(
8039 &old_two,
8040 &new_tip,
8041 "checkout: moving from old-branch to new-branch",
8042 )],
8043 );
8044 fs::write(
8045 git_dir.join("config"),
8046 b"[branch \"old-branch\"]\n\tremote = .\n\tmerge = refs/heads/main\n[branch \"new-branch\"]\n\tremote = .\n\tmerge = refs/heads/main\n",
8047 )
8048 .expect("test operation should succeed");
8049 assert_eq!(
8050 resolve_revision_symbolic_full_name(&git_dir, ObjectFormat::Sha1, "@{-1}")
8051 .expect("test operation should succeed"),
8052 Some("refs/heads/old-branch".to_string())
8053 );
8054 assert_eq!(
8055 resolve_revision(&git_dir, ObjectFormat::Sha1, "@{-1}@{0}")
8056 .expect("test operation should succeed"),
8057 old_two
8058 );
8059 assert_eq!(
8060 resolve_revision(&git_dir, ObjectFormat::Sha1, "@{-1}@{1}")
8061 .expect("test operation should succeed"),
8062 old_one
8063 );
8064 assert_eq!(
8065 resolve_revision_symbolic_full_name(&git_dir, ObjectFormat::Sha1, "HEAD@{u}")
8066 .expect("test operation should succeed"),
8067 Some("refs/heads/main".to_string())
8068 );
8069 assert_eq!(
8070 resolve_revision_symbolic_full_name(&git_dir, ObjectFormat::Sha1, "@@{u}")
8071 .expect("test operation should succeed"),
8072 Some("refs/heads/main".to_string())
8073 );
8074 assert_eq!(
8075 resolve_revision_symbolic_full_name(&git_dir, ObjectFormat::Sha1, "@{-1}@{u}")
8076 .expect("test operation should succeed"),
8077 Some("refs/heads/main".to_string())
8078 );
8079 let nested = resolve_revision(&git_dir, ObjectFormat::Sha1, "@{0}@{0}")
8080 .expect_err("test operation should fail");
8081 assert!(
8082 matches!(&nested, GitError::InvalidFormat(_)),
8083 "unexpected error: {nested:?}"
8084 );
8085 fs::remove_dir_all(git_dir).expect("test operation should succeed");
8086 }
8087
8088 #[test]
8089 fn at_selector_composes_with_parent_suffix() {
8090 let git_dir = temp_git_dir();
8093 let mut db = FileObjectDatabase::from_git_dir(&git_dir, ObjectFormat::Sha1);
8094 let tree = db
8095 .write_object(EncodedObject::new(ObjectType::Tree, Vec::new()))
8096 .expect("test operation should succeed");
8097 let parent = write_dated_commit(&mut db, tree, Vec::new(), b"parent\n", 1000);
8098 let child = write_dated_commit(&mut db, tree, vec![parent], b"child\n", 2000);
8099 fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n")
8100 .expect("test operation should succeed");
8101 set_branch(&git_dir, "main", &child);
8102 write_head_reflog(
8103 &git_dir,
8104 &[
8105 (&zero_oid(), &parent, "commit (initial): parent"),
8106 (&parent, &child, "commit: child"),
8107 ],
8108 );
8109 write_branch_reflog(
8110 &git_dir,
8111 "main",
8112 &[
8113 (&zero_oid(), &parent, "commit (initial): parent"),
8114 (&parent, &child, "commit: child"),
8115 ],
8116 );
8117 assert_eq!(
8118 resolve_revision(&git_dir, ObjectFormat::Sha1, "@{0}")
8119 .expect("test operation should succeed"),
8120 child
8121 );
8122 assert_eq!(
8123 resolve_revision(&git_dir, ObjectFormat::Sha1, "@{0}^")
8124 .expect("test operation should succeed"),
8125 parent
8126 );
8127 assert_eq!(
8128 resolve_revision(&git_dir, ObjectFormat::Sha1, "HEAD@{0}~1")
8129 .expect("test operation should succeed"),
8130 parent
8131 );
8132 assert_eq!(
8133 resolve_revision(&git_dir, ObjectFormat::Sha1, "HEAD@{0}^{tree}")
8134 .expect("test operation should succeed"),
8135 tree
8136 );
8137 fs::remove_dir_all(git_dir).expect("test operation should succeed");
8138 }
8139
8140 #[test]
8141 fn resolve_at_selector_rejects_unsupported_and_malformed() {
8142 let git_dir = temp_git_dir();
8143 fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n")
8144 .expect("test operation should succeed");
8145 set_branch(&git_dir, "main", &test_oid(0x60));
8146 let unsupported = resolve_revision(&git_dir, ObjectFormat::Sha1, "@{yesterday}")
8148 .expect_err("test operation should fail");
8149 assert!(
8150 matches!(&unsupported, GitError::Unsupported(_)),
8151 "unexpected error: {unsupported:?}"
8152 );
8153 let bad_base = resolve_revision(&git_dir, ObjectFormat::Sha1, "main@{-1}")
8155 .expect_err("test operation should fail");
8156 assert!(
8157 matches!(&bad_base, GitError::InvalidFormat(_)),
8158 "unexpected error: {bad_base:?}"
8159 );
8160 fs::remove_dir_all(git_dir).expect("test operation should succeed");
8161 }
8162
8163 fn test_oid(byte: u8) -> ObjectId {
8164 ObjectId::from_hex(ObjectFormat::Sha1, &format!("{byte:02x}").repeat(20))
8165 .expect("test operation should succeed")
8166 }
8167
8168 fn zero_oid() -> ObjectId {
8169 ObjectId::from_hex(ObjectFormat::Sha1, &"0".repeat(40))
8170 .expect("test operation should succeed")
8171 }
8172
8173 fn oid_set(oids: impl IntoIterator<Item = ObjectId>) -> HashSet<ObjectId> {
8174 oids.into_iter().collect()
8175 }
8176
8177 fn assert_oid_set(
8178 actual: impl IntoIterator<Item = ObjectId>,
8179 expected: impl IntoIterator<Item = ObjectId>,
8180 ) {
8181 assert_eq!(oid_set(actual), oid_set(expected));
8182 }
8183
8184 struct SetupRevisionsFixture {
8185 git_dir: PathBuf,
8186 worktree: PathBuf,
8187 db: FileObjectDatabase,
8188 base: ObjectId,
8189 tip: ObjectId,
8190 left: ObjectId,
8191 right: ObjectId,
8192 side: ObjectId,
8193 skipped: ObjectId,
8194 }
8195
8196 fn setup_revisions_fixture() -> SetupRevisionsFixture {
8197 let git_dir = temp_git_dir();
8198 let worktree = git_dir.with_extension("worktree");
8199 fs::create_dir_all(&worktree).expect("test operation should succeed");
8200 fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n")
8201 .expect("test operation should succeed");
8202 let mut db = FileObjectDatabase::from_git_dir(&git_dir, ObjectFormat::Sha1);
8203 let tree = write_tree(&mut db, &[]);
8204 let base = write_test_commit(&mut db, tree, Vec::new(), b"base\n");
8205 let tip = write_test_commit(&mut db, tree, vec![base], b"tip\n");
8206 let left = write_test_commit(&mut db, tree, vec![base], b"left\n");
8207 let right = write_test_commit(&mut db, tree, vec![base], b"right\n");
8208 let side = write_test_commit(&mut db, tree, Vec::new(), b"side\n");
8209 let skipped = write_test_commit(&mut db, tree, Vec::new(), b"skipped\n");
8210 set_branch(&git_dir, "main", &tip);
8211 set_branch(&git_dir, "base", &base);
8212 set_branch(&git_dir, "left", &left);
8213 set_branch(&git_dir, "right", &right);
8214 set_branch(&git_dir, "side", &side);
8215 set_ref(&git_dir, "refs/heads/skip/topic", &skipped);
8216 SetupRevisionsFixture {
8217 git_dir,
8218 worktree,
8219 db,
8220 base,
8221 tip,
8222 left,
8223 right,
8224 side,
8225 skipped,
8226 }
8227 }
8228
8229 fn run_setup<const N: usize>(
8230 fixture: &SetupRevisionsFixture,
8231 args: [&str; N],
8232 ) -> Result<SetupRevisions> {
8233 let args = args.iter().map(|arg| arg.to_string()).collect::<Vec<_>>();
8234 setup_revisions(
8235 &args,
8236 &RevisionSetupContext {
8237 git_dir: &fixture.git_dir,
8238 worktree_root: Some(&fixture.worktree),
8239 cwd: &fixture.worktree,
8240 format: ObjectFormat::Sha1,
8241 reader: &fixture.db,
8242 config: None,
8243 },
8244 )
8245 }
8246
8247 fn set_branch(git_dir: &Path, branch: &str, oid: &ObjectId) {
8248 set_ref(git_dir, &format!("refs/heads/{branch}"), oid);
8249 }
8250
8251 fn set_ref(git_dir: &Path, name: &str, oid: &ObjectId) {
8252 let refs = FileRefStore::new(git_dir, ObjectFormat::Sha1);
8253 let mut tx = refs.transaction();
8254 tx.update(RefUpdate {
8255 name: name.to_string(),
8256 expected: None,
8257 new: RefTarget::Direct(*oid),
8258 reflog: None,
8259 });
8260 tx.commit().expect("test operation should succeed");
8261 }
8262
8263 fn write_head_reflog(git_dir: &Path, entries: &[(&ObjectId, &ObjectId, &str)]) {
8264 write_reflog_for(git_dir, "HEAD", entries);
8265 }
8266
8267 fn write_branch_reflog(git_dir: &Path, branch: &str, entries: &[(&ObjectId, &ObjectId, &str)]) {
8268 write_reflog_for(git_dir, &format!("refs/heads/{branch}"), entries);
8269 }
8270
8271 fn write_reflog_for(git_dir: &Path, name: &str, entries: &[(&ObjectId, &ObjectId, &str)]) {
8272 let refs = FileRefStore::new(git_dir, ObjectFormat::Sha1);
8273 let entries: Vec<ReflogEntry> = entries
8274 .iter()
8275 .map(|(old, new, message)| ReflogEntry {
8276 old_oid: (*old).clone(),
8277 new_oid: (*new).clone(),
8278 committer: b"Example User <example@example.invalid> 1000 +0000".to_vec(),
8279 message: message.as_bytes().to_vec(),
8280 })
8281 .collect();
8282 refs.write_reflog(name, &entries)
8283 .expect("test operation should succeed");
8284 }
8285
8286 fn write_test_commit<W: ObjectWriter>(
8287 db: &mut W,
8288 tree: ObjectId,
8289 parents: Vec<ObjectId>,
8290 message: &[u8],
8291 ) -> ObjectId {
8292 let commit = Commit {
8293 tree,
8294 parents,
8295 author: b"Example User <example@example.invalid> 0 +0000".to_vec(),
8296 committer: b"Example User <example@example.invalid> 0 +0000".to_vec(),
8297 encoding: None,
8298 message: message.to_vec(),
8299 };
8300 db.write_object(EncodedObject::new(ObjectType::Commit, commit.write()))
8301 .expect("test operation should succeed")
8302 }
8303
8304 fn write_dated_commit<W: ObjectWriter>(
8305 db: &mut W,
8306 tree: ObjectId,
8307 parents: Vec<ObjectId>,
8308 message: &[u8],
8309 when: i64,
8310 ) -> ObjectId {
8311 let ident = format!("Example User <example@example.invalid> {when} +0000");
8312 let commit = Commit {
8313 tree,
8314 parents,
8315 author: ident.clone().into_bytes(),
8316 committer: ident.into_bytes(),
8317 encoding: None,
8318 message: message.to_vec(),
8319 };
8320 db.write_object(EncodedObject::new(ObjectType::Commit, commit.write()))
8321 .expect("test operation should succeed")
8322 }
8323
8324 fn write_tree<W: ObjectWriter>(db: &mut W, entries: &[(u32, &[u8], &ObjectId)]) -> ObjectId {
8325 let tree = sley_object::Tree {
8326 entries: entries
8327 .iter()
8328 .map(|(mode, name, oid)| sley_object::TreeEntry {
8329 mode: *mode,
8330 name: BString::from(*name),
8331 oid: (*oid).clone(),
8332 })
8333 .collect(),
8334 };
8335 db.write_object(EncodedObject::new(ObjectType::Tree, tree.write()))
8336 .expect("test operation should succeed")
8337 }
8338
8339 fn test_index_entry(path: &[u8], oid: &ObjectId, stage: u16) -> sley_index::IndexEntry {
8340 sley_index::IndexEntry {
8341 ctime_seconds: 0,
8342 ctime_nanoseconds: 0,
8343 mtime_seconds: 0,
8344 mtime_nanoseconds: 0,
8345 dev: 0,
8346 ino: 0,
8347 mode: 0o100644,
8348 uid: 0,
8349 gid: 0,
8350 size: 0,
8351 oid: *oid,
8352 flags: (stage & 0x3) << 12,
8353 flags_extended: 0,
8354 path: BString::from(path),
8355 }
8356 }
8357
8358 fn temp_git_dir() -> std::path::PathBuf {
8359 let path = std::env::temp_dir().join(format!(
8360 "sley-rev-{}-{}",
8361 std::process::id(),
8362 TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
8363 ));
8364 fs::create_dir_all(&path).expect("test operation should succeed");
8365 path
8366 }
8367
8368 struct PanicReader;
8372 impl ObjectReader for PanicReader {
8373 fn read_object(&self, oid: &ObjectId) -> Result<std::sync::Arc<EncodedObject>> {
8374 Err(GitError::not_found(format!(
8375 "object reader must not be used for {oid}; graph should cover it"
8376 )))
8377 }
8378 }
8379
8380 struct CountingReader<'a> {
8381 inner: &'a FileObjectDatabase,
8382 reads: Cell<usize>,
8383 }
8384
8385 impl<'a> CountingReader<'a> {
8386 fn new(inner: &'a FileObjectDatabase) -> Self {
8387 Self {
8388 inner,
8389 reads: Cell::new(0),
8390 }
8391 }
8392 }
8393
8394 impl ObjectReader for CountingReader<'_> {
8395 fn read_object(&self, oid: &ObjectId) -> Result<std::sync::Arc<EncodedObject>> {
8396 self.reads.set(self.reads.get() + 1);
8397 self.inner.read_object(oid)
8398 }
8399 }
8400
8401 fn generation_numbers(parents: &HashMap<ObjectId, Vec<ObjectId>>) -> HashMap<ObjectId, u32> {
8405 let mut generations: HashMap<ObjectId, u32> = HashMap::new();
8406 loop {
8409 let mut changed = false;
8410 for (oid, oid_parents) in parents {
8411 let candidate = oid_parents
8412 .iter()
8413 .map(|parent| generations.get(parent).copied().unwrap_or(0))
8414 .max()
8415 .unwrap_or(0)
8416 + 1;
8417 if generations.get(oid).copied() != Some(candidate) {
8418 let current = generations.get(oid).copied().unwrap_or(0);
8420 if candidate > current {
8421 generations.insert(*oid, candidate);
8422 changed = true;
8423 }
8424 }
8425 }
8426 if !changed {
8427 break;
8428 }
8429 }
8430 generations
8431 }
8432
8433 fn write_commit_graph_file(
8438 git_dir: &Path,
8439 format: ObjectFormat,
8440 reader: &impl ObjectReader,
8441 commits: &[ObjectId],
8442 ) {
8443 let mut parents_map: HashMap<ObjectId, Vec<ObjectId>> = HashMap::new();
8444 for oid in commits {
8445 parents_map.insert(
8446 *oid,
8447 commit_parents(reader, format, oid).expect("test operation should succeed"),
8448 );
8449 }
8450 let generations = generation_numbers(&parents_map);
8451 let entries: Vec<sley_formats::CommitGraphWriteEntry> = commits
8452 .iter()
8453 .map(|oid| {
8454 let object = reader
8455 .read_object(oid)
8456 .expect("test operation should succeed");
8457 let commit =
8458 Commit::parse_ref(format, &object.body).expect("test operation should succeed");
8459 let commit_time =
8460 commit_committer_time(commit.committer).unwrap_or(0).max(0) as u64;
8461 sley_formats::CommitGraphWriteEntry {
8462 oid: *oid,
8463 tree: commit.tree,
8464 parents: commit.parents,
8465 generation: generations.get(oid).copied().unwrap_or(1),
8466 commit_time,
8467 bloom_filter: None,
8468 }
8469 })
8470 .collect();
8471 let bytes = CommitGraph::write(format, &entries).expect("test operation should succeed");
8472 let info = git_dir.join("objects").join("info");
8473 fs::create_dir_all(&info).expect("test operation should succeed");
8474 fs::write(info.join("commit-graph"), bytes).expect("test operation should succeed");
8475 }
8476
8477 fn remove_commit_graph(git_dir: &Path) {
8478 let path = git_dir.join("objects").join("info").join("commit-graph");
8479 if path.exists() {
8480 fs::remove_file(path).expect("test operation should succeed");
8481 }
8482 }
8483
8484 fn build_history(git_dir: &Path, format: ObjectFormat) -> (FileObjectDatabase, Vec<ObjectId>) {
8504 let mut db = FileObjectDatabase::from_git_dir(git_dir, format);
8505 let tree = db
8506 .write_object(EncodedObject::new(ObjectType::Tree, Vec::new()))
8507 .expect("test operation should succeed");
8508 let mut t = 1000i64;
8509 let mut commit = |db: &mut FileObjectDatabase, parents: Vec<ObjectId>, msg: &[u8]| {
8510 t += 1;
8511 write_dated_commit(db, tree, parents, msg, t)
8512 };
8513 let root = commit(&mut db, vec![], b"root\n");
8514 let a = commit(&mut db, vec![root], b"a\n");
8515 let b = commit(&mut db, vec![root], b"b\n");
8516 let c = commit(&mut db, vec![a], b"c\n");
8517 let d = commit(&mut db, vec![b], b"d\n");
8518 let e = commit(&mut db, vec![b], b"e\n");
8519 let m1 = commit(&mut db, vec![c.clone(), d.clone()], b"m1\n");
8520 let f = commit(&mut db, vec![d.clone(), e.clone()], b"f\n");
8521 let g = commit(&mut db, vec![f.clone()], b"g\n");
8522 let oct = commit(&mut db, vec![m1.clone(), g.clone(), f.clone()], b"oct\n");
8523 let x1 = commit(&mut db, vec![a, b], b"x1\n");
8524 let x2 = commit(&mut db, vec![b, a], b"x2\n");
8525 let all = vec![root, a, b, c, d, e, m1, f, g, oct, x1, x2];
8526 (db, all)
8527 }
8528
8529 #[test]
8530 fn graph_backed_walks_match_object_only_walks() {
8531 let git_dir = temp_git_dir();
8532 let format = ObjectFormat::Sha1;
8533 let (db, all) = build_history(&git_dir, format);
8534
8535 remove_commit_graph(&git_dir);
8539 let baseline = collect_walk_results(&git_dir, format, &db, &all);
8540
8541 write_commit_graph_file(&git_dir, format, &db, &all);
8542 let with_graph = collect_walk_results(&git_dir, format, &db, &all);
8543
8544 assert_eq!(
8545 baseline, with_graph,
8546 "graph-backed walk diverged from object-only walk"
8547 );
8548 fs::remove_dir_all(git_dir).expect("test operation should succeed");
8549 }
8550
8551 type WalkResult = (String, String, bool, Vec<String>, Vec<String>, Vec<String>);
8552
8553 fn collect_walk_results(
8556 git_dir: &Path,
8557 format: ObjectFormat,
8558 reader: &impl ObjectReader,
8559 all: &[ObjectId],
8560 ) -> Vec<WalkResult> {
8561 let mut out = Vec::new();
8562 for left in all {
8563 for right in all {
8564 let anc = is_ancestor(git_dir, format, reader, left, right)
8565 .expect("test operation should succeed");
8566 let mut bases: Vec<String> = merge_bases(git_dir, format, reader, left, right)
8567 .expect("test operation should succeed")
8568 .iter()
8569 .map(|oid| oid.to_hex())
8570 .collect();
8571 bases.sort();
8572 let asym = RevisionRange::Asymmetric {
8573 start: left.to_hex(),
8574 end: right.to_hex(),
8575 };
8576 let mut asym_set: Vec<String> =
8577 resolve_revision_range(git_dir, format, reader, &asym)
8578 .expect("test operation should succeed")
8579 .iter()
8580 .map(|oid| oid.to_hex())
8581 .collect();
8582 asym_set.sort();
8583 let sym = RevisionRange::Symmetric {
8584 left: left.to_hex(),
8585 right: right.to_hex(),
8586 };
8587 let mut sym_set: Vec<String> =
8588 resolve_revision_range(git_dir, format, reader, &sym)
8589 .expect("test operation should succeed")
8590 .iter()
8591 .map(|oid| oid.to_hex())
8592 .collect();
8593 sym_set.sort();
8594 out.push((left.to_hex(), right.to_hex(), anc, bases, asym_set, sym_set));
8595 }
8596 }
8597 out
8598 }
8599
8600 #[test]
8601 fn graph_backed_merge_base_handles_octopus_and_criss_cross() {
8602 let git_dir = temp_git_dir();
8603 let format = ObjectFormat::Sha1;
8604 let (db, all) = build_history(&git_dir, format);
8605 let (a, b) = (all[1].clone(), all[2].clone());
8607 let (m1, oct) = (all[6].clone(), all[9].clone());
8608 let (x1, x2) = (all[10].clone(), all[11].clone());
8609
8610 write_commit_graph_file(&git_dir, format, &db, &all);
8611
8612 let mut xbases =
8614 merge_bases(&git_dir, format, &db, &x1, &x2).expect("test operation should succeed");
8615 xbases.sort_by_key(|oid| oid.to_hex());
8616 let mut expected = vec![a, b];
8617 expected.sort_by_key(|oid| oid.to_hex());
8618 assert_eq!(xbases, expected, "criss-cross must yield two merge bases");
8619
8620 assert!(
8622 is_ancestor(&git_dir, format, &db, &m1, &oct).expect("test operation should succeed")
8623 );
8624 assert_eq!(
8626 merge_bases(&git_dir, format, &db, &m1, &oct).expect("test operation should succeed"),
8627 vec![m1.clone()]
8628 );
8629 fs::remove_dir_all(git_dir).expect("test operation should succeed");
8630 }
8631
8632 #[test]
8633 fn graph_backed_queries_avoid_object_reads() {
8634 let git_dir = temp_git_dir();
8635 let format = ObjectFormat::Sha1;
8636 let (db, all) = build_history(&git_dir, format);
8637 write_commit_graph_file(&git_dir, format, &db, &all);
8638 let (root, a, oct, x1, x2) = (
8639 all[0].clone(),
8640 all[1].clone(),
8641 all[9].clone(),
8642 all[10].clone(),
8643 all[11].clone(),
8644 );
8645
8646 assert!(
8649 is_ancestor(&git_dir, format, &PanicReader, &root, &oct)
8650 .expect("test operation should succeed")
8651 );
8652 assert!(
8653 !is_ancestor(&git_dir, format, &PanicReader, &oct, &root)
8654 .expect("test operation should succeed")
8655 );
8656 assert!(
8657 is_ancestor(&git_dir, format, &PanicReader, &a, &oct)
8658 .expect("test operation should succeed")
8659 );
8660
8661 let bases = merge_bases(&git_dir, format, &PanicReader, &x1, &x2)
8662 .expect("test operation should succeed");
8663 assert_eq!(bases.len(), 2, "criss-cross bases via graph only");
8664
8665 let range = RevisionRange::Asymmetric {
8669 start: a.to_hex(),
8670 end: oct.to_hex(),
8671 };
8672 let mut included: Vec<String> = resolve_revision_range(&git_dir, format, &db, &range)
8673 .expect("test operation should succeed")
8674 .iter()
8675 .map(|oid| oid.to_hex())
8676 .collect();
8677 included.sort();
8678 assert!(included.contains(&oct.to_hex()));
8679 assert!(
8680 !included.contains(&root.to_hex()),
8681 "root is an ancestor of A, excluded"
8682 );
8683
8684 remove_commit_graph(&git_dir);
8687 let object_bases =
8688 merge_bases(&git_dir, format, &db, &x1, &x2).expect("test operation should succeed");
8689 let mut object_range: Vec<String> = resolve_revision_range(&git_dir, format, &db, &range)
8690 .expect("test operation should succeed")
8691 .iter()
8692 .map(|oid| oid.to_hex())
8693 .collect();
8694 object_range.sort();
8695 write_commit_graph_file(&git_dir, format, &db, &all);
8696 let graph_bases = merge_bases(&git_dir, format, &PanicReader, &x1, &x2)
8697 .expect("test operation should succeed");
8698 assert_eq!(object_bases, graph_bases);
8699 assert_eq!(object_range, included, "range walk diverged with graph");
8700 fs::remove_dir_all(git_dir).expect("test operation should succeed");
8701 }
8702
8703 #[test]
8704 fn graph_backed_parent_suffix_matches_object_walk() {
8705 let git_dir = temp_git_dir();
8706 let format = ObjectFormat::Sha1;
8707 let (db, all) = build_history(&git_dir, format);
8708 let oct = all[9].clone();
8709 let (m1, g, f) = (all[6].clone(), all[8].clone(), all[7].clone());
8710
8711 remove_commit_graph(&git_dir);
8713 let base_p1 = resolve_revision_with_reader(&git_dir, format, &db, &format!("{oct}^1"))
8714 .expect("test operation should succeed");
8715 let base_p2 = resolve_revision_with_reader(&git_dir, format, &db, &format!("{oct}^2"))
8716 .expect("test operation should succeed");
8717 let base_p3 = resolve_revision_with_reader(&git_dir, format, &db, &format!("{oct}^3"))
8718 .expect("test operation should succeed");
8719 let base_first = resolve_revision_with_reader(&git_dir, format, &db, &format!("{oct}~1"))
8720 .expect("test operation should succeed");
8721 assert_eq!((&base_p1, &base_p2, &base_p3), (&m1, &g, &f));
8722 assert_eq!(base_first, m1);
8723
8724 write_commit_graph_file(&git_dir, format, &db, &all);
8726 assert_eq!(
8727 resolve_revision_with_reader(&git_dir, format, &PanicReader, &format!("{oct}^2"))
8728 .expect("test operation should succeed"),
8729 base_p2
8730 );
8731 assert_eq!(
8732 resolve_revision_with_reader(&git_dir, format, &PanicReader, &format!("{oct}~1"))
8733 .expect("test operation should succeed"),
8734 base_first
8735 );
8736 fs::remove_dir_all(git_dir).expect("test operation should succeed");
8737 }
8738
8739 #[test]
8740 fn missing_or_unparseable_graph_falls_back_to_objects() {
8741 let git_dir = temp_git_dir();
8742 let format = ObjectFormat::Sha1;
8743 let (db, all) = build_history(&git_dir, format);
8744 let (a, oct) = (all[1].clone(), all[9].clone());
8745 let object_answer =
8746 is_ancestor(&git_dir, format, &db, &a, &oct).expect("test operation should succeed");
8747
8748 let info = git_dir.join("objects").join("info");
8751 fs::create_dir_all(&info).expect("test operation should succeed");
8752 fs::write(info.join("commit-graph"), b"not a real commit graph")
8753 .expect("test operation should succeed");
8754 assert_eq!(
8755 is_ancestor(&git_dir, format, &db, &a, &oct).expect("test operation should succeed"),
8756 object_answer
8757 );
8758 write_commit_graph_file(&git_dir, format, &db, &all[..3]);
8760 assert_eq!(
8761 is_ancestor(&git_dir, format, &db, &a, &oct).expect("test operation should succeed"),
8762 object_answer
8763 );
8764 assert_eq!(
8765 merge_bases(&git_dir, format, &db, &all[10], &all[11])
8766 .expect("test operation should succeed"),
8767 {
8768 remove_commit_graph(&git_dir);
8769 merge_bases(&git_dir, format, &db, &all[10], &all[11])
8770 .expect("test operation should succeed")
8771 }
8772 );
8773 fs::remove_dir_all(git_dir).expect("test operation should succeed");
8774 }
8775
8776 #[test]
8777 fn commit_graph_chain_is_consulted() {
8778 let git_dir = temp_git_dir();
8779 let format = ObjectFormat::Sha1;
8780 let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
8783 let tree = db
8784 .write_object(EncodedObject::new(ObjectType::Tree, Vec::new()))
8785 .expect("test operation should succeed");
8786 let root = write_dated_commit(&mut db, tree, vec![], b"root\n", 1000);
8787 let mid = write_dated_commit(&mut db, tree, vec![root], b"mid\n", 1001);
8788 let tip = write_dated_commit(&mut db, tree, vec![mid.clone()], b"tip\n", 1002);
8789 let commits = [root, mid.clone(), tip.clone()];
8790
8791 let parents_map: HashMap<ObjectId, Vec<ObjectId>> = commits
8792 .iter()
8793 .map(|oid| {
8794 (
8795 *oid,
8796 commit_parents(&db, format, oid).expect("test operation should succeed"),
8797 )
8798 })
8799 .collect();
8800 let generations = generation_numbers(&parents_map);
8801 let entries: Vec<sley_formats::CommitGraphWriteEntry> = commits
8802 .iter()
8803 .map(|oid| sley_formats::CommitGraphWriteEntry {
8804 oid: *oid,
8805 tree,
8806 parents: parents_map[oid].clone(),
8807 generation: generations[oid],
8808 commit_time: 0,
8809 bloom_filter: None,
8810 })
8811 .collect();
8812 let bytes = CommitGraph::write(format, &entries).expect("test operation should succeed");
8813
8814 let graphs = git_dir.join("objects").join("info").join("commit-graphs");
8816 fs::create_dir_all(&graphs).expect("test operation should succeed");
8817 let hash = sley_core::digest_bytes(format, &bytes)
8818 .expect("test operation should succeed")
8819 .to_hex();
8820 fs::write(graphs.join(format!("graph-{hash}.graph")), &bytes)
8821 .expect("test operation should succeed");
8822 fs::write(graphs.join("commit-graph-chain"), format!("{hash}\n"))
8823 .expect("test operation should succeed");
8824
8825 assert!(
8828 !git_dir
8829 .join("objects")
8830 .join("info")
8831 .join("commit-graph")
8832 .exists()
8833 );
8834 assert!(
8835 is_ancestor(&git_dir, format, &PanicReader, &root, &tip)
8836 .expect("test operation should succeed")
8837 );
8838 assert_eq!(
8839 merge_bases(&git_dir, format, &PanicReader, &mid, &tip)
8840 .expect("test operation should succeed"),
8841 vec![mid.clone()]
8842 );
8843
8844 let linked = git_dir.join("worktrees").join("linked");
8849 fs::create_dir_all(&linked).expect("test operation should succeed");
8850 fs::write(linked.join("commondir"), "../..\n").expect("test operation should succeed");
8851 assert!(
8852 is_ancestor(&linked, format, &PanicReader, &root, &tip)
8853 .expect("test operation should succeed")
8854 );
8855 fs::remove_dir_all(git_dir).expect("test operation should succeed");
8856 }
8857
8858 #[test]
8859 fn count_commit_metadata_uses_partial_direct_commit_graph() {
8860 let git_dir = temp_git_dir();
8861 let format = ObjectFormat::Sha1;
8862 let db = FileObjectDatabase::from_git_dir(&git_dir, format);
8863 let commits = build_linear_history(&git_dir, 5);
8864 write_commit_graph_file(&git_dir, format, &db, &commits[..3]);
8865
8866 let reader = CountingReader::new(&db);
8867 let count = count_commit_metadata(&git_dir, format, &reader, [commits[4]], false)
8868 .expect("count should succeed");
8869 assert_eq!(count, 5);
8870 assert_eq!(
8871 reader.reads.get(),
8872 2,
8873 "only commits newer than the partial graph should be object-read"
8874 );
8875 fs::remove_dir_all(git_dir).expect("test operation should succeed");
8876 }
8877
8878 #[test]
8879 fn commit_graph_tree_oid_returns_tree_without_object_read() {
8880 let git_dir = temp_git_dir();
8881 let format = ObjectFormat::Sha1;
8882 let db = FileObjectDatabase::from_git_dir(&git_dir, format);
8883 let commits = build_linear_history(&git_dir, 3);
8884 write_commit_graph_file(&git_dir, format, &db, &commits);
8885
8886 for oid in &commits {
8887 let object = db.read_object(oid).expect("test operation should succeed");
8888 let commit =
8889 Commit::parse_ref(format, &object.body).expect("test operation should succeed");
8890 assert_eq!(
8891 commit_graph_tree_oid(&git_dir, format, oid)
8892 .expect("test operation should succeed"),
8893 Some(commit.tree)
8894 );
8895 }
8896 fs::remove_dir_all(git_dir).expect("test operation should succeed");
8897 }
8898
8899 fn test_commit_graph(format: ObjectFormat, parent: &ObjectId, child: &ObjectId) -> Vec<u8> {
8900 let tree = ObjectId::from_hex(format, "4b825dc642cb6eb9a060e54bf8d69288fbee4904")
8901 .expect("test operation should succeed");
8902 let mut oidf = vec![0u8; 256 * 4];
8903 let parent_first = parent.as_bytes()[0] as usize;
8904 let child_first = child.as_bytes()[0] as usize;
8905 for idx in 0..256 {
8906 let count = u32::from(idx >= parent_first) + u32::from(idx >= child_first);
8907 oidf[idx * 4..idx * 4 + 4].copy_from_slice(&count.to_be_bytes());
8908 }
8909 let mut oidl = Vec::new();
8910 oidl.extend_from_slice(parent.as_bytes());
8911 oidl.extend_from_slice(child.as_bytes());
8912 let mut cdat = Vec::new();
8913 cdat.extend_from_slice(&commit_graph_cdat_entry(
8914 &tree,
8915 0x7000_0000,
8916 0x7000_0000,
8917 1,
8918 1,
8919 ));
8920 cdat.extend_from_slice(&commit_graph_cdat_entry(&tree, 0, 0x7000_0000, 2, 2));
8921 commit_graph_file(
8922 format,
8923 &[(*b"OIDF", oidf), (*b"OIDL", oidl), (*b"CDAT", cdat)],
8924 )
8925 }
8926
8927 fn commit_graph_cdat_entry(
8928 tree: &ObjectId,
8929 parent_one: u32,
8930 parent_two: u32,
8931 generation: u32,
8932 commit_time: u64,
8933 ) -> Vec<u8> {
8934 let mut out = Vec::new();
8935 out.extend_from_slice(tree.as_bytes());
8936 out.extend_from_slice(&parent_one.to_be_bytes());
8937 out.extend_from_slice(&parent_two.to_be_bytes());
8938 let high = (generation << 2) | ((commit_time >> 32) as u32 & 0x3);
8939 out.extend_from_slice(&high.to_be_bytes());
8940 out.extend_from_slice(&(commit_time as u32).to_be_bytes());
8941 out
8942 }
8943
8944 fn commit_graph_file(format: ObjectFormat, chunks: &[([u8; 4], Vec<u8>)]) -> Vec<u8> {
8945 let lookup_len = (chunks.len() + 1) * 12;
8946 let mut out = Vec::new();
8947 out.extend_from_slice(b"CGPH");
8948 out.push(1);
8949 out.push(match format {
8950 ObjectFormat::Sha1 => 1,
8951 ObjectFormat::Sha256 => 2,
8952 });
8953 out.push(chunks.len() as u8);
8954 out.push(0);
8955 let mut offset = (8 + lookup_len) as u64;
8956 for (id, data) in chunks {
8957 out.extend_from_slice(id);
8958 out.extend_from_slice(&offset.to_be_bytes());
8959 offset += data.len() as u64;
8960 }
8961 out.extend_from_slice(&[0, 0, 0, 0]);
8962 out.extend_from_slice(&offset.to_be_bytes());
8963 for (_id, data) in chunks {
8964 out.extend_from_slice(data);
8965 }
8966 let checksum =
8967 sley_core::digest_bytes(format, &out).expect("test operation should succeed");
8968 out.extend_from_slice(checksum.as_bytes());
8969 out
8970 }
8971
8972 fn build_linear_history(git_dir: &std::path::Path, n: usize) -> Vec<ObjectId> {
8977 let mut db = FileObjectDatabase::from_git_dir(git_dir, ObjectFormat::Sha1);
8978 let tree = db
8979 .write_object(EncodedObject::new(ObjectType::Tree, Vec::new()))
8980 .expect("write empty tree");
8981 let mut oids = Vec::new();
8982 let mut parents = Vec::new();
8983 for i in 0..n {
8984 let oid = write_dated_commit(
8985 &mut db,
8986 tree,
8987 parents.clone(),
8988 format!("c{i}\n").as_bytes(),
8989 100 + i as i64,
8990 );
8991 parents = vec![oid];
8992 oids.push(oid);
8993 }
8994 oids
8995 }
8996
8997 fn walk_oids<R: ObjectReader>(walk: RevWalk<'_, R>) -> Vec<ObjectId> {
8998 walk.collect_all()
8999 .expect("walk succeeds")
9000 .into_iter()
9001 .map(|m| m.oid)
9002 .collect()
9003 }
9004
9005 #[test]
9006 fn revwalk_commit_date_order_newest_first() {
9007 let git_dir = temp_git_dir();
9008 let db = FileObjectDatabase::from_git_dir(&git_dir, ObjectFormat::Sha1);
9009 let oids = build_linear_history(&git_dir, 4); let tip = *oids.last().expect("tip");
9011 let got = walk_oids(RevWalk::new(&git_dir, ObjectFormat::Sha1, &db, [tip]));
9012 let mut expected = oids.clone();
9013 expected.reverse(); assert_eq!(got, expected);
9015 fs::remove_dir_all(git_dir).expect("cleanup");
9016 }
9017
9018 #[test]
9019 fn revwalk_max_count_limits_output() {
9020 let git_dir = temp_git_dir();
9021 let db = FileObjectDatabase::from_git_dir(&git_dir, ObjectFormat::Sha1);
9022 let oids = build_linear_history(&git_dir, 5);
9023 let tip = *oids.last().expect("tip");
9024 let got =
9025 walk_oids(RevWalk::new(&git_dir, ObjectFormat::Sha1, &db, [tip]).max_count(Some(2)));
9026 assert_eq!(got, vec![oids[4], oids[3]]);
9027 fs::remove_dir_all(git_dir).expect("cleanup");
9028 }
9029
9030 #[test]
9031 fn revwalk_skip_then_limit() {
9032 let git_dir = temp_git_dir();
9033 let db = FileObjectDatabase::from_git_dir(&git_dir, ObjectFormat::Sha1);
9034 let oids = build_linear_history(&git_dir, 5);
9035 let tip = *oids.last().expect("tip");
9036 let got = walk_oids(
9037 RevWalk::new(&git_dir, ObjectFormat::Sha1, &db, [tip])
9038 .skip(1)
9039 .max_count(Some(2)),
9040 );
9041 assert_eq!(got, vec![oids[3], oids[2]]);
9043 fs::remove_dir_all(git_dir).expect("cleanup");
9044 }
9045
9046 #[test]
9047 fn revwalk_delegates_match_old_limited_walk() {
9048 let git_dir = temp_git_dir();
9052 let db = FileObjectDatabase::from_git_dir(&git_dir, ObjectFormat::Sha1);
9053 let oids = build_linear_history(&git_dir, 6);
9054 let tip = *oids.last().expect("tip");
9055 let via_fn = walk_commit_metadata_date_ordered_limited(
9056 &git_dir,
9057 ObjectFormat::Sha1,
9058 &db,
9059 [tip],
9060 false,
9061 3,
9062 )
9063 .expect("limited walk")
9064 .into_iter()
9065 .map(|m| m.oid)
9066 .collect::<Vec<_>>();
9067 let via_walk = walk_oids(
9068 RevWalk::new(&git_dir, ObjectFormat::Sha1, &db, [tip])
9069 .order(RevWalkOrder::CommitDate)
9070 .max_count(Some(3)),
9071 );
9072 assert_eq!(via_fn, via_walk);
9073 fs::remove_dir_all(git_dir).expect("cleanup");
9074 }
9075
9076 #[test]
9077 fn revwalk_first_parent_follows_one_line() {
9078 let git_dir = temp_git_dir();
9079 let mut db = FileObjectDatabase::from_git_dir(&git_dir, ObjectFormat::Sha1);
9080 let tree = db
9081 .write_object(EncodedObject::new(ObjectType::Tree, Vec::new()))
9082 .expect("tree");
9083 let base = write_dated_commit(&mut db, tree, vec![], b"base\n", 100);
9084 let side = write_dated_commit(&mut db, tree, vec![base], b"side\n", 110);
9085 let main = write_dated_commit(&mut db, tree, vec![base], b"main\n", 120);
9086 let merge = write_dated_commit(&mut db, tree, vec![main, side], b"merge\n", 130);
9087 let first_parent =
9088 walk_oids(RevWalk::new(&git_dir, ObjectFormat::Sha1, &db, [merge]).first_parent(true));
9089 assert_eq!(first_parent, vec![merge, main, base]);
9091 assert!(!first_parent.contains(&side));
9092 fs::remove_dir_all(git_dir).expect("cleanup");
9093 }
9094
9095 #[test]
9096 fn revwalk_date_window_filters_and_prunes() {
9097 let git_dir = temp_git_dir();
9098 let db = FileObjectDatabase::from_git_dir(&git_dir, ObjectFormat::Sha1);
9099 let oids = build_linear_history(&git_dir, 5); let tip = *oids.last().expect("tip");
9101 let got = walk_oids(
9103 RevWalk::new(&git_dir, ObjectFormat::Sha1, &db, [tip]).date_window(RevWalkDateWindow {
9104 min_time: Some(102),
9105 max_time: Some(103),
9106 }),
9107 );
9108 assert_eq!(got, vec![oids[3], oids[2]]);
9109 fs::remove_dir_all(git_dir).expect("cleanup");
9110 }
9111
9112 #[test]
9113 fn revwalk_pathspec_is_carried_but_not_pruning() {
9114 let git_dir = temp_git_dir();
9118 let db = FileObjectDatabase::from_git_dir(&git_dir, ObjectFormat::Sha1);
9119 let oids = build_linear_history(&git_dir, 3);
9120 let tip = *oids.last().expect("tip");
9121 let spec = Pathspec::parse(
9122 [b"does/not/exist".as_slice()],
9123 PathspecMatchMagic::default(),
9124 )
9125 .expect("pathspec");
9126 let walk = RevWalk::new(&git_dir, ObjectFormat::Sha1, &db, [tip]).pathspec(spec.clone());
9127 assert_eq!(walk.pathspec_ref(), &spec);
9128 let got = walk_oids(walk);
9129 assert_eq!(got.len(), 3, "pathspec must not prune in STAGE-A");
9130 fs::remove_dir_all(git_dir).expect("cleanup");
9131 }
9132}