1use std::cmp::Ordering;
45use std::collections::{BinaryHeap, HashMap, HashSet};
46use std::io::Write;
47
48use clap::{Parser, ValueEnum};
49use mkit_core::Hash;
50use mkit_core::layout::RepoLayout;
51use mkit_core::object::{Commit, Object};
52use mkit_core::ops::graph::collect_ancestor_set;
53use mkit_core::ops::merge::find_merge_base;
54use mkit_core::refs;
55use mkit_core::store::ObjectStore;
56
57use super::revspec;
58use crate::clap_shim;
59use crate::exit;
60use crate::format;
61use crate::signal;
62
63mod dateparse;
64
65const DEFAULT_ABBREV: usize = 7;
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
70enum Format {
71 Default,
72 Oneline,
73 Json,
74}
75
76#[derive(Debug, Parser)]
77#[command(
78 name = "mkit log",
79 about = "Show commit history.",
80 disable_help_flag = false,
81 disable_version_flag = true
82)]
83#[allow(clippy::struct_excessive_bools)] struct LogOpts {
85 #[arg(long)]
88 oneline: bool,
89
90 #[arg(long, value_enum)]
92 format: Option<Format>,
93
94 #[arg(short = 'n')]
96 limit: Option<usize>,
97
98 #[arg(long = "abbrev-commit")]
101 abbrev_commit: bool,
102
103 #[arg(long, value_name = "N", num_args = 0..=1, default_missing_value = "7")]
106 abbrev: Option<usize>,
107
108 #[arg(long)]
111 graph: bool,
112
113 #[arg(long, value_name = "PATTERN")]
121 author: Option<String>,
122
123 #[arg(long, value_name = "PATTERN")]
127 grep: Option<String>,
128
129 #[arg(long, value_name = "DATE")]
134 since: Option<String>,
135
136 #[arg(long, value_name = "DATE")]
139 until: Option<String>,
140
141 #[arg(long = "no-merges")]
145 no_merges: bool,
146
147 #[arg(long = "first-parent")]
152 first_parent: bool,
153
154 start: Option<String>,
158}
159
160impl LogOpts {
161 fn render_format(&self) -> Format {
164 match self.format {
165 Some(f) => f,
166 None if self.oneline => Format::Oneline,
167 None => Format::Default,
168 }
169 }
170
171 fn abbrev_len(&self) -> Option<usize> {
177 if let Some(n) = self.abbrev {
178 return Some(n);
179 }
180 if self.abbrev_commit || self.render_format() == Format::Oneline {
181 return Some(DEFAULT_ABBREV);
182 }
183 None
184 }
185}
186
187#[must_use]
188pub fn run(args: &[String]) -> u8 {
189 let opts = match clap_shim::parse::<LogOpts>("mkit log", args) {
190 Ok(o) => o,
191 Err(code) => return code,
192 };
193 let fmt = opts.render_format();
194 let abbrev = opts.abbrev_len();
195 let _ = opts.graph; let now = std::time::SystemTime::now()
200 .duration_since(std::time::UNIX_EPOCH)
201 .map_or(0, |d| d.as_secs());
202 let since = match opts.since.as_deref() {
203 Some(s) => match dateparse::parse_date(s, now) {
204 Ok(t) => Some(t),
205 Err(msg) => return emit_err(&format!("--since: {msg}"), exit::USAGE),
206 },
207 None => None,
208 };
209 let until = match opts.until.as_deref() {
210 Some(s) => match dateparse::parse_date(s, now) {
211 Ok(t) => Some(t),
212 Err(msg) => return emit_err(&format!("--until: {msg}"), exit::USAGE),
213 },
214 None => None,
215 };
216
217 let cwd = match std::env::current_dir() {
218 Ok(p) => p,
219 Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
220 };
221 let layout = match super::resolve_layout(&cwd) {
222 Ok(layout) => layout,
223 Err(code) => return code,
224 };
225 let store = match ObjectStore::open(&layout) {
226 Ok(s) => s,
227 Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
228 };
229 let selection = parse_rev_arg(opts.start.as_deref());
232 let (tips, excluded) = match resolve_selection(&store, &layout, &selection) {
233 Ok(Some(v)) => v,
234 Ok(None) => {
235 if opts.start.is_none() && matches!(fmt, Format::Default | Format::Oneline) {
237 let mut stderr = std::io::stderr().lock();
238 let _ = writeln!(stderr, "no commits yet");
239 }
240 return exit::OK;
241 }
242 Err(msg) => return emit_err(&msg, exit::DATAERR),
243 };
244
245 let ordered = match ordered_commits_opts(&store, &tips, &excluded, opts.first_parent) {
246 Ok(v) => v,
247 Err(code) => return code,
248 };
249
250 let mut stdout = std::io::stdout().lock();
251 let limit = opts.limit.unwrap_or(usize::MAX);
255 let mut shown = 0usize;
256 for (hash, c) in &ordered {
257 if signal::is_shutdown() {
258 return exit::TEMPFAIL;
259 }
260 if shown >= limit {
261 break;
262 }
263 if !commit_matches(
264 c,
265 opts.author.as_deref(),
266 opts.grep.as_deref(),
267 since,
268 until,
269 opts.no_merges,
270 ) {
271 continue;
272 }
273 render_commit(&mut stdout, fmt, abbrev, hash, c);
274 shown += 1;
275 }
276 exit::OK
277}
278
279fn commit_matches(
282 c: &Commit,
283 author: Option<&str>,
284 grep: Option<&str>,
285 since: Option<u64>,
286 until: Option<u64>,
287 no_merges: bool,
288) -> bool {
289 if no_merges && c.parents.len() > 1 {
290 return false;
291 }
292 if since.is_some_and(|s| c.timestamp < s) {
293 return false;
294 }
295 if until.is_some_and(|u| c.timestamp > u) {
296 return false;
297 }
298 if let Some(pat) = author
299 && !(format::short_identity(&c.author).contains(pat)
300 || format::full_identity(&c.author).contains(pat))
301 {
302 return false;
303 }
304 if let Some(pat) = grep {
305 let msg = String::from_utf8_lossy(&c.message);
306 if !msg.contains(pat) {
307 return false;
308 }
309 }
310 true
311}
312
313type WalkSet = (Vec<Hash>, HashSet<Hash>);
316
317enum RevSelection {
319 Default,
321 Single(String),
323 Range { exclude: String, include: String },
325 Symmetric { a: String, b: String },
327}
328
329fn parse_rev_arg(arg: Option<&str>) -> RevSelection {
332 let Some(s) = arg else {
333 return RevSelection::Default;
334 };
335 let to_spec = |side: &str| {
336 if side.is_empty() {
337 "HEAD".to_string()
338 } else {
339 side.to_string()
340 }
341 };
342 if let Some((a, b)) = s.split_once("...") {
344 return RevSelection::Symmetric {
345 a: to_spec(a),
346 b: to_spec(b),
347 };
348 }
349 if let Some((a, b)) = s.split_once("..") {
350 return RevSelection::Range {
351 exclude: to_spec(a),
352 include: to_spec(b),
353 };
354 }
355 RevSelection::Single(s.to_string())
356}
357
358fn resolve_selection(
362 store: &ObjectStore,
363 layout: &RepoLayout,
364 sel: &RevSelection,
365) -> Result<Option<WalkSet>, String> {
366 let mut excluded: HashSet<Hash> = HashSet::new();
367 let tips: Vec<Hash> = match sel {
368 RevSelection::Default => match resolve_tip(store, layout, None)? {
369 Some(h) => vec![h],
370 None => return Ok(None),
371 },
372 RevSelection::Single(spec) => match resolve_tip(store, layout, Some(spec))? {
373 Some(h) => vec![h],
374 None => return Ok(None),
375 },
376 RevSelection::Range { exclude, include } => {
377 let Some(inc) = resolve_tip(store, layout, Some(include))? else {
378 return Ok(None);
379 };
380 if let Some(a) = resolve_tip(store, layout, Some(exclude))? {
381 collect_ancestor_set(store, a, &mut excluded)
382 .map_err(|e| format!("walk range base: {e}"))?;
383 }
384 vec![inc]
385 }
386 RevSelection::Symmetric { a, b } => {
387 let ra = resolve_tip(store, layout, Some(a))?;
388 let rb = resolve_tip(store, layout, Some(b))?;
389 if let (Some(x), Some(y)) = (ra, rb)
391 && let Some(mb) =
392 find_merge_base(store, x, y).map_err(|e| format!("merge base: {e}"))?
393 {
394 collect_ancestor_set(store, mb, &mut excluded)
395 .map_err(|e| format!("walk merge base: {e}"))?;
396 }
397 let tips: Vec<Hash> = ra.into_iter().chain(rb).collect();
398 if tips.is_empty() {
399 return Ok(None);
400 }
401 tips
402 }
403 };
404 Ok(Some((tips, excluded)))
405}
406
407fn resolve_tip(
412 store: &ObjectStore,
413 layout: &RepoLayout,
414 spec: Option<&str>,
415) -> Result<Option<Hash>, String> {
416 let raw = match spec {
417 None | Some("HEAD") => refs::resolve_head(layout).ok().flatten(),
418 Some(s) => Some(
419 revspec::resolve_revision(store, layout, s)
420 .map_err(|e| format!("bad revision '{s}': {e}"))?,
421 ),
422 };
423 Ok(raw.map(|h| peel_tags(store, h)))
424}
425
426const MAX_TAG_DEPTH: usize = 16;
428
429pub(super) fn peel_tags(store: &ObjectStore, mut h: Hash) -> Hash {
434 for _ in 0..MAX_TAG_DEPTH {
435 match store.read_object(&h) {
436 Ok(Object::Tag(t)) => h = t.target,
437 _ => break,
438 }
439 }
440 h
441}
442
443struct HeapItem {
446 timestamp: u64,
447 hash: Hash,
448}
449
450impl Ord for HeapItem {
451 fn cmp(&self, other: &Self) -> Ordering {
452 self.timestamp
453 .cmp(&other.timestamp)
454 .then_with(|| self.hash.cmp(&other.hash))
455 }
456}
457impl PartialOrd for HeapItem {
458 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
459 Some(self.cmp(other))
460 }
461}
462impl PartialEq for HeapItem {
463 fn eq(&self, other: &Self) -> bool {
464 self.cmp(other) == Ordering::Equal
465 }
466}
467impl Eq for HeapItem {}
468
469const MAX_LOG_COMMITS: usize = 1_000_000;
471
472pub(super) fn ordered_commits(
482 store: &ObjectStore,
483 tips: &[Hash],
484 excluded: &HashSet<Hash>,
485) -> Result<Vec<(Hash, Commit)>, u8> {
486 ordered_commits_opts(store, tips, excluded, false)
487}
488
489pub(super) fn ordered_commits_opts(
496 store: &ObjectStore,
497 tips: &[Hash],
498 excluded: &HashSet<Hash>,
499 first_parent: bool,
500) -> Result<Vec<(Hash, Commit)>, u8> {
501 let mut commits: HashMap<Hash, Commit> = HashMap::new();
503 let mut stack: Vec<Hash> = tips.to_vec();
504 while let Some(h) = stack.pop() {
505 if excluded.contains(&h) || commits.contains_key(&h) {
506 continue;
507 }
508 if commits.len() >= MAX_LOG_COMMITS {
509 break;
510 }
511 let c = match store.read_object(&h) {
512 Ok(Object::Commit(c)) => c,
513 Ok(_) => {
514 return Err(emit_err(
515 &format!("not a commit: {}", format::hex_hash(&h)),
516 exit::DATAERR,
517 ));
518 }
519 Err(e) => {
520 return Err(emit_err(
521 &format!("read {}: {e}", format::hex_hash(&h)),
522 exit::DATAERR,
523 ));
524 }
525 };
526 let parents: &[Hash] = if first_parent {
527 c.parents.get(..1).unwrap_or(&[])
528 } else {
529 &c.parents
530 };
531 for p in parents {
532 if !excluded.contains(p) {
533 stack.push(*p);
534 }
535 }
536 commits.insert(h, c);
537 }
538
539 let mut indeg: HashMap<Hash, usize> = commits.keys().map(|h| (*h, 0usize)).collect();
541 for c in commits.values() {
542 for p in &c.parents {
543 if let Some(d) = indeg.get_mut(p) {
544 *d += 1;
545 }
546 }
547 }
548
549 let mut heap: BinaryHeap<HeapItem> = BinaryHeap::new();
551 for (h, c) in &commits {
552 if indeg[h] == 0 {
553 heap.push(HeapItem {
554 timestamp: c.timestamp,
555 hash: *h,
556 });
557 }
558 }
559 let mut out: Vec<(Hash, Commit)> = Vec::with_capacity(commits.len());
560 while let Some(item) = heap.pop() {
561 let c = commits[&item.hash].clone();
562 for p in &c.parents {
563 if let Some(d) = indeg.get_mut(p) {
564 *d -= 1;
565 if *d == 0 {
566 heap.push(HeapItem {
567 timestamp: commits[p].timestamp,
568 hash: *p,
569 });
570 }
571 }
572 }
573 out.push((item.hash, c));
574 }
575 Ok(out)
576}
577
578fn render_commit(
580 out: &mut impl Write,
581 fmt: Format,
582 abbrev: Option<usize>,
583 hash: &Hash,
584 c: &Commit,
585) {
586 let full_message: String = String::from_utf8_lossy(&c.message).into_owned();
587 let title = full_message.lines().next().unwrap_or("");
588 match fmt {
589 Format::Oneline => {
590 let id = format::short_hash(hash, abbrev.unwrap_or(DEFAULT_ABBREV));
591 let _ = writeln!(out, "{id} {title}");
592 }
593 Format::Default => {
594 let id = match abbrev {
595 Some(n) => format::short_hash(hash, n),
596 None => format::hex_hash(hash),
597 };
598 let _ = writeln!(out, "commit {id}");
599 let _ = writeln!(out, "Author: {}", format::short_identity(&c.author));
600 let _ = writeln!(out, "Date: {}", format::human_date_utc(c.timestamp));
601 let _ = writeln!(out);
602 for line in full_message.lines() {
605 if line.is_empty() {
606 let _ = writeln!(out);
607 } else {
608 let _ = writeln!(out, " {line}");
609 }
610 }
611 let _ = writeln!(out);
612 }
613 Format::Json => {
614 emit_json_entry(out, hash, c, title, &full_message);
615 }
616 }
617}
618
619fn emit_json_entry(
636 out: &mut impl Write,
637 hash: &mkit_core::Hash,
638 c: &mkit_core::object::Commit,
639 title: &str,
640 full_message: &str,
641) {
642 let _ = out.write_all(b"{");
643 let _ = write!(out, "\"hash\":\"{}\"", format::hex_hash(hash));
644 let _ = out.write_all(b",\"parents\":[");
645 for (i, p) in c.parents.iter().enumerate() {
646 if i > 0 {
647 let _ = out.write_all(b",");
648 }
649 let _ = write!(out, "\"{}\"", format::hex_hash(p));
650 }
651 let _ = out.write_all(b"]");
652 let _ = write!(out, ",\"tree\":\"{}\"", format::hex_hash(&c.tree_hash));
653 let _ = write!(
654 out,
655 ",\"author\":\"{}\"",
656 format::json_escape(&format::full_identity(&c.author))
657 );
658 let _ = write!(out, ",\"timestamp\":{}", c.timestamp);
659 let _ = write!(out, ",\"title\":\"{}\"", format::json_escape(title));
660 let _ = write!(
661 out,
662 ",\"message\":\"{}\"",
663 format::json_escape(full_message)
664 );
665 let _ = out.write_all(b"}\n");
666}
667
668use super::error as emit_err;
669
670#[cfg(test)]
671mod tests {
672 use super::*;
673
674 #[test]
675 fn render_format_explicit_format_wins_over_oneline() {
676 let opts = LogOpts {
677 oneline: true,
678 format: Some(Format::Default),
679 limit: None,
680 abbrev_commit: false,
681 abbrev: None,
682 graph: false,
683 author: None,
684 grep: None,
685 since: None,
686 until: None,
687 no_merges: false,
688 first_parent: false,
689 start: None,
690 };
691 assert_eq!(opts.render_format(), Format::Default);
692 }
693
694 #[test]
695 fn render_format_oneline_alone_resolves_to_oneline() {
696 let opts = LogOpts {
697 oneline: true,
698 format: None,
699 limit: None,
700 abbrev_commit: false,
701 abbrev: None,
702 graph: false,
703 author: None,
704 grep: None,
705 since: None,
706 until: None,
707 no_merges: false,
708 first_parent: false,
709 start: None,
710 };
711 assert_eq!(opts.render_format(), Format::Oneline);
712 }
713
714 #[test]
715 fn render_format_default_when_no_flags() {
716 let opts = LogOpts {
717 oneline: false,
718 format: None,
719 limit: None,
720 abbrev_commit: false,
721 abbrev: None,
722 graph: false,
723 author: None,
724 grep: None,
725 since: None,
726 until: None,
727 no_merges: false,
728 first_parent: false,
729 start: None,
730 };
731 assert_eq!(opts.render_format(), Format::Default);
732 }
733
734 #[test]
735 fn render_format_json_via_format_flag() {
736 let opts = LogOpts {
737 oneline: false,
738 format: Some(Format::Json),
739 limit: None,
740 abbrev_commit: false,
741 abbrev: None,
742 graph: false,
743 author: None,
744 grep: None,
745 since: None,
746 until: None,
747 no_merges: false,
748 first_parent: false,
749 start: None,
750 };
751 assert_eq!(opts.render_format(), Format::Json);
752 }
753
754 fn opts_for_abbrev(oneline: bool, abbrev_commit: bool, abbrev: Option<usize>) -> LogOpts {
755 LogOpts {
756 oneline,
757 format: None,
758 limit: None,
759 abbrev_commit,
760 abbrev,
761 graph: false,
762 author: None,
763 grep: None,
764 since: None,
765 until: None,
766 no_merges: false,
767 first_parent: false,
768 start: None,
769 }
770 }
771
772 #[test]
773 fn abbrev_len_off_by_default() {
774 assert_eq!(opts_for_abbrev(false, false, None).abbrev_len(), None);
775 }
776
777 #[test]
778 fn abbrev_len_default_for_oneline_and_abbrev_commit() {
779 assert_eq!(
780 opts_for_abbrev(true, false, None).abbrev_len(),
781 Some(DEFAULT_ABBREV)
782 );
783 assert_eq!(
784 opts_for_abbrev(false, true, None).abbrev_len(),
785 Some(DEFAULT_ABBREV)
786 );
787 }
788
789 #[test]
790 fn abbrev_len_explicit_value_wins() {
791 assert_eq!(
792 opts_for_abbrev(true, false, Some(12)).abbrev_len(),
793 Some(12)
794 );
795 }
796
797 fn commit_for(
798 parents: Vec<Hash>,
799 author: mkit_core::object::Identity,
800 message: &str,
801 timestamp: u64,
802 ) -> Commit {
803 Commit::new_unannotated(
804 mkit_core::hash::ZERO,
805 parents,
806 author,
807 [0u8; 32],
808 message.as_bytes().to_vec(),
809 timestamp,
810 [0u8; 64],
811 )
812 }
813
814 #[test]
815 fn commit_matches_no_filters_passes_everything() {
816 let c = commit_for(
817 vec![],
818 mkit_core::object::Identity::opaque(b"alice".to_vec()),
819 "m",
820 100,
821 );
822 assert!(commit_matches(&c, None, None, None, None, false));
823 }
824
825 #[test]
826 fn commit_matches_author_is_substring_against_short_and_full_identity() {
827 let c = commit_for(
828 vec![],
829 mkit_core::object::Identity::opaque(b"alice".to_vec()),
830 "m",
831 100,
832 );
833 assert!(commit_matches(&c, Some("alice"), None, None, None, false));
834 assert!(!commit_matches(&c, Some("bob"), None, None, None, false));
835 }
836
837 #[test]
838 fn commit_matches_grep_checks_message_substring() {
839 let c = commit_for(
840 vec![],
841 mkit_core::object::Identity::opaque(b"alice".to_vec()),
842 "fix: widget overflow",
843 100,
844 );
845 assert!(commit_matches(&c, None, Some("widget"), None, None, false));
846 assert!(!commit_matches(&c, None, Some("gadget"), None, None, false));
847 }
848
849 #[test]
850 fn commit_matches_since_until_bound_the_timestamp() {
851 let c = commit_for(
852 vec![],
853 mkit_core::object::Identity::opaque(b"a".to_vec()),
854 "m",
855 500,
856 );
857 assert!(commit_matches(&c, None, None, Some(500), Some(500), false));
858 assert!(commit_matches(&c, None, None, Some(499), Some(501), false));
859 assert!(!commit_matches(&c, None, None, Some(501), None, false));
860 assert!(!commit_matches(&c, None, None, None, Some(499), false));
861 }
862
863 #[test]
864 fn commit_matches_no_merges_excludes_multi_parent_commits() {
865 let solo = commit_for(
866 vec![],
867 mkit_core::object::Identity::opaque(b"a".to_vec()),
868 "m",
869 1,
870 );
871 let merge = commit_for(
872 vec![mkit_core::hash::ZERO, mkit_core::hash::ZERO],
873 mkit_core::object::Identity::opaque(b"a".to_vec()),
874 "m",
875 1,
876 );
877 assert!(commit_matches(&solo, None, None, None, None, true));
878 assert!(!commit_matches(&merge, None, None, None, None, true));
879 assert!(commit_matches(&merge, None, None, None, None, false));
881 }
882}