1use std::cmp::Ordering;
33use std::collections::{BinaryHeap, HashMap, HashSet};
34use std::io::Write;
35
36use clap::{Parser, ValueEnum};
37use mkit_core::Hash;
38use mkit_core::object::{Commit, Object};
39use mkit_core::ops::graph::collect_ancestor_set;
40use mkit_core::ops::merge::find_merge_base;
41use mkit_core::refs;
42use mkit_core::store::ObjectStore;
43
44use super::revspec;
45use crate::clap_shim;
46use crate::exit;
47use crate::format;
48use crate::signal;
49
50const DEFAULT_ABBREV: usize = 7;
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
55enum Format {
56 Default,
57 Oneline,
58 Json,
59}
60
61#[derive(Debug, Parser)]
62#[command(
63 name = "mkit log",
64 about = "Show commit history.",
65 disable_help_flag = false,
66 disable_version_flag = true
67)]
68struct LogOpts {
69 #[arg(long)]
72 oneline: bool,
73
74 #[arg(long, value_enum)]
76 format: Option<Format>,
77
78 #[arg(short = 'n')]
80 limit: Option<usize>,
81
82 #[arg(long = "abbrev-commit")]
85 abbrev_commit: bool,
86
87 #[arg(long, value_name = "N", num_args = 0..=1, default_missing_value = "7")]
90 abbrev: Option<usize>,
91
92 #[arg(long)]
95 graph: bool,
96
97 start: Option<String>,
101}
102
103impl LogOpts {
104 fn render_format(&self) -> Format {
107 match self.format {
108 Some(f) => f,
109 None if self.oneline => Format::Oneline,
110 None => Format::Default,
111 }
112 }
113
114 fn abbrev_len(&self) -> Option<usize> {
120 if let Some(n) = self.abbrev {
121 return Some(n);
122 }
123 if self.abbrev_commit || self.render_format() == Format::Oneline {
124 return Some(DEFAULT_ABBREV);
125 }
126 None
127 }
128}
129
130#[must_use]
131pub fn run(args: &[String]) -> u8 {
132 let opts = match clap_shim::parse::<LogOpts>("mkit log", args) {
133 Ok(o) => o,
134 Err(code) => return code,
135 };
136 let fmt = opts.render_format();
137 let abbrev = opts.abbrev_len();
138 let _ = opts.graph; let cwd = match std::env::current_dir() {
141 Ok(p) => p,
142 Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
143 };
144 let mkit_dir = cwd.join(mkit_core::MKIT_DIR);
145 let store = match ObjectStore::open(&cwd) {
146 Ok(s) => s,
147 Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
148 };
149 let selection = parse_rev_arg(opts.start.as_deref());
152 let (tips, excluded) = match resolve_selection(&store, &mkit_dir, &selection) {
153 Ok(Some(v)) => v,
154 Ok(None) => {
155 if opts.start.is_none() && matches!(fmt, Format::Default | Format::Oneline) {
157 let mut stderr = std::io::stderr().lock();
158 let _ = writeln!(stderr, "no commits yet");
159 }
160 return exit::OK;
161 }
162 Err(msg) => return emit_err(&msg, exit::DATAERR),
163 };
164
165 let ordered = match ordered_commits(&store, &tips, &excluded) {
166 Ok(v) => v,
167 Err(code) => return code,
168 };
169
170 let mut stdout = std::io::stdout().lock();
171 let limit = opts.limit.unwrap_or(usize::MAX);
172 for (hash, c) in ordered.iter().take(limit) {
173 if signal::is_shutdown() {
174 return exit::TEMPFAIL;
175 }
176 render_commit(&mut stdout, fmt, abbrev, hash, c);
177 }
178 exit::OK
179}
180
181type WalkSet = (Vec<Hash>, HashSet<Hash>);
184
185enum RevSelection {
187 Default,
189 Single(String),
191 Range { exclude: String, include: String },
193 Symmetric { a: String, b: String },
195}
196
197fn parse_rev_arg(arg: Option<&str>) -> RevSelection {
200 let Some(s) = arg else {
201 return RevSelection::Default;
202 };
203 let to_spec = |side: &str| {
204 if side.is_empty() {
205 "HEAD".to_string()
206 } else {
207 side.to_string()
208 }
209 };
210 if let Some((a, b)) = s.split_once("...") {
212 return RevSelection::Symmetric {
213 a: to_spec(a),
214 b: to_spec(b),
215 };
216 }
217 if let Some((a, b)) = s.split_once("..") {
218 return RevSelection::Range {
219 exclude: to_spec(a),
220 include: to_spec(b),
221 };
222 }
223 RevSelection::Single(s.to_string())
224}
225
226fn resolve_selection(
230 store: &ObjectStore,
231 mkit_dir: &std::path::Path,
232 sel: &RevSelection,
233) -> Result<Option<WalkSet>, String> {
234 let mut excluded: HashSet<Hash> = HashSet::new();
235 let tips: Vec<Hash> = match sel {
236 RevSelection::Default => match resolve_tip(store, mkit_dir, None)? {
237 Some(h) => vec![h],
238 None => return Ok(None),
239 },
240 RevSelection::Single(spec) => match resolve_tip(store, mkit_dir, Some(spec))? {
241 Some(h) => vec![h],
242 None => return Ok(None),
243 },
244 RevSelection::Range { exclude, include } => {
245 let Some(inc) = resolve_tip(store, mkit_dir, Some(include))? else {
246 return Ok(None);
247 };
248 if let Some(a) = resolve_tip(store, mkit_dir, Some(exclude))? {
249 collect_ancestor_set(store, a, &mut excluded)
250 .map_err(|e| format!("walk range base: {e}"))?;
251 }
252 vec![inc]
253 }
254 RevSelection::Symmetric { a, b } => {
255 let ra = resolve_tip(store, mkit_dir, Some(a))?;
256 let rb = resolve_tip(store, mkit_dir, Some(b))?;
257 if let (Some(x), Some(y)) = (ra, rb)
259 && let Some(mb) =
260 find_merge_base(store, x, y).map_err(|e| format!("merge base: {e}"))?
261 {
262 collect_ancestor_set(store, mb, &mut excluded)
263 .map_err(|e| format!("walk merge base: {e}"))?;
264 }
265 let tips: Vec<Hash> = ra.into_iter().chain(rb).collect();
266 if tips.is_empty() {
267 return Ok(None);
268 }
269 tips
270 }
271 };
272 Ok(Some((tips, excluded)))
273}
274
275fn resolve_tip(
280 store: &ObjectStore,
281 mkit_dir: &std::path::Path,
282 spec: Option<&str>,
283) -> Result<Option<Hash>, String> {
284 let raw = match spec {
285 None | Some("HEAD") => refs::resolve_head(mkit_dir).ok().flatten(),
286 Some(s) => Some(
287 revspec::resolve_revision(store, mkit_dir, s)
288 .map_err(|e| format!("bad revision '{s}': {e}"))?,
289 ),
290 };
291 Ok(raw.map(|h| peel_tags(store, h)))
292}
293
294const MAX_TAG_DEPTH: usize = 16;
296
297fn peel_tags(store: &ObjectStore, mut h: Hash) -> Hash {
301 for _ in 0..MAX_TAG_DEPTH {
302 match store.read_object(&h) {
303 Ok(Object::Tag(t)) => h = t.target,
304 _ => break,
305 }
306 }
307 h
308}
309
310struct HeapItem {
313 timestamp: u64,
314 hash: Hash,
315}
316
317impl Ord for HeapItem {
318 fn cmp(&self, other: &Self) -> Ordering {
319 self.timestamp
320 .cmp(&other.timestamp)
321 .then_with(|| self.hash.cmp(&other.hash))
322 }
323}
324impl PartialOrd for HeapItem {
325 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
326 Some(self.cmp(other))
327 }
328}
329impl PartialEq for HeapItem {
330 fn eq(&self, other: &Self) -> bool {
331 self.cmp(other) == Ordering::Equal
332 }
333}
334impl Eq for HeapItem {}
335
336const MAX_LOG_COMMITS: usize = 1_000_000;
338
339fn ordered_commits(
346 store: &ObjectStore,
347 tips: &[Hash],
348 excluded: &HashSet<Hash>,
349) -> Result<Vec<(Hash, Commit)>, u8> {
350 let mut commits: HashMap<Hash, Commit> = HashMap::new();
352 let mut stack: Vec<Hash> = tips.to_vec();
353 while let Some(h) = stack.pop() {
354 if excluded.contains(&h) || commits.contains_key(&h) {
355 continue;
356 }
357 if commits.len() >= MAX_LOG_COMMITS {
358 break;
359 }
360 let c = match store.read_object(&h) {
361 Ok(Object::Commit(c)) => c,
362 Ok(_) => {
363 return Err(emit_err(
364 &format!("not a commit: {}", format::hex_hash(&h)),
365 exit::DATAERR,
366 ));
367 }
368 Err(e) => {
369 return Err(emit_err(
370 &format!("read {}: {e}", format::hex_hash(&h)),
371 exit::DATAERR,
372 ));
373 }
374 };
375 for p in &c.parents {
376 if !excluded.contains(p) {
377 stack.push(*p);
378 }
379 }
380 commits.insert(h, c);
381 }
382
383 let mut indeg: HashMap<Hash, usize> = commits.keys().map(|h| (*h, 0usize)).collect();
385 for c in commits.values() {
386 for p in &c.parents {
387 if let Some(d) = indeg.get_mut(p) {
388 *d += 1;
389 }
390 }
391 }
392
393 let mut heap: BinaryHeap<HeapItem> = BinaryHeap::new();
395 for (h, c) in &commits {
396 if indeg[h] == 0 {
397 heap.push(HeapItem {
398 timestamp: c.timestamp,
399 hash: *h,
400 });
401 }
402 }
403 let mut out: Vec<(Hash, Commit)> = Vec::with_capacity(commits.len());
404 while let Some(item) = heap.pop() {
405 let c = commits[&item.hash].clone();
406 for p in &c.parents {
407 if let Some(d) = indeg.get_mut(p) {
408 *d -= 1;
409 if *d == 0 {
410 heap.push(HeapItem {
411 timestamp: commits[p].timestamp,
412 hash: *p,
413 });
414 }
415 }
416 }
417 out.push((item.hash, c));
418 }
419 Ok(out)
420}
421
422fn render_commit(
424 out: &mut impl Write,
425 fmt: Format,
426 abbrev: Option<usize>,
427 hash: &Hash,
428 c: &Commit,
429) {
430 let full_message: String = String::from_utf8_lossy(&c.message).into_owned();
431 let title = full_message.lines().next().unwrap_or("");
432 match fmt {
433 Format::Oneline => {
434 let id = format::short_hash(hash, abbrev.unwrap_or(DEFAULT_ABBREV));
435 let _ = writeln!(out, "{id} {title}");
436 }
437 Format::Default => {
438 let id = match abbrev {
439 Some(n) => format::short_hash(hash, n),
440 None => format::hex_hash(hash),
441 };
442 let _ = writeln!(out, "commit {id}");
443 let _ = writeln!(out, "Author: {}", format::short_identity(&c.author));
444 let _ = writeln!(out, "Date: {}", format::human_date_utc(c.timestamp));
445 let _ = writeln!(out);
446 for line in full_message.lines() {
449 if line.is_empty() {
450 let _ = writeln!(out);
451 } else {
452 let _ = writeln!(out, " {line}");
453 }
454 }
455 let _ = writeln!(out);
456 }
457 Format::Json => {
458 emit_json_entry(out, hash, c, title, &full_message);
459 }
460 }
461}
462
463fn emit_json_entry(
480 out: &mut impl Write,
481 hash: &mkit_core::Hash,
482 c: &mkit_core::object::Commit,
483 title: &str,
484 full_message: &str,
485) {
486 let _ = out.write_all(b"{");
487 let _ = write!(out, "\"hash\":\"{}\"", format::hex_hash(hash));
488 let _ = out.write_all(b",\"parents\":[");
489 for (i, p) in c.parents.iter().enumerate() {
490 if i > 0 {
491 let _ = out.write_all(b",");
492 }
493 let _ = write!(out, "\"{}\"", format::hex_hash(p));
494 }
495 let _ = out.write_all(b"]");
496 let _ = write!(out, ",\"tree\":\"{}\"", format::hex_hash(&c.tree_hash));
497 let _ = write!(
498 out,
499 ",\"author\":\"{}\"",
500 format::json_escape(&format::full_identity(&c.author))
501 );
502 let _ = write!(out, ",\"timestamp\":{}", c.timestamp);
503 let _ = write!(out, ",\"title\":\"{}\"", format::json_escape(title));
504 let _ = write!(
505 out,
506 ",\"message\":\"{}\"",
507 format::json_escape(full_message)
508 );
509 let _ = out.write_all(b"}\n");
510}
511
512fn emit_err(msg: &str, code: u8) -> u8 {
513 let mut stderr = std::io::stderr().lock();
514 let _ = writeln!(stderr, "error: {msg}");
515 code
516}
517
518#[cfg(test)]
519mod tests {
520 use super::*;
521
522 #[test]
523 fn render_format_explicit_format_wins_over_oneline() {
524 let opts = LogOpts {
525 oneline: true,
526 format: Some(Format::Default),
527 limit: None,
528 abbrev_commit: false,
529 abbrev: None,
530 graph: false,
531 start: None,
532 };
533 assert_eq!(opts.render_format(), Format::Default);
534 }
535
536 #[test]
537 fn render_format_oneline_alone_resolves_to_oneline() {
538 let opts = LogOpts {
539 oneline: true,
540 format: None,
541 limit: None,
542 abbrev_commit: false,
543 abbrev: None,
544 graph: false,
545 start: None,
546 };
547 assert_eq!(opts.render_format(), Format::Oneline);
548 }
549
550 #[test]
551 fn render_format_default_when_no_flags() {
552 let opts = LogOpts {
553 oneline: false,
554 format: None,
555 limit: None,
556 abbrev_commit: false,
557 abbrev: None,
558 graph: false,
559 start: None,
560 };
561 assert_eq!(opts.render_format(), Format::Default);
562 }
563
564 #[test]
565 fn render_format_json_via_format_flag() {
566 let opts = LogOpts {
567 oneline: false,
568 format: Some(Format::Json),
569 limit: None,
570 abbrev_commit: false,
571 abbrev: None,
572 graph: false,
573 start: None,
574 };
575 assert_eq!(opts.render_format(), Format::Json);
576 }
577
578 fn opts_for_abbrev(oneline: bool, abbrev_commit: bool, abbrev: Option<usize>) -> LogOpts {
579 LogOpts {
580 oneline,
581 format: None,
582 limit: None,
583 abbrev_commit,
584 abbrev,
585 graph: false,
586 start: None,
587 }
588 }
589
590 #[test]
591 fn abbrev_len_off_by_default() {
592 assert_eq!(opts_for_abbrev(false, false, None).abbrev_len(), None);
593 }
594
595 #[test]
596 fn abbrev_len_default_for_oneline_and_abbrev_commit() {
597 assert_eq!(
598 opts_for_abbrev(true, false, None).abbrev_len(),
599 Some(DEFAULT_ABBREV)
600 );
601 assert_eq!(
602 opts_for_abbrev(false, true, None).abbrev_len(),
603 Some(DEFAULT_ABBREV)
604 );
605 }
606
607 #[test]
608 fn abbrev_len_explicit_value_wins() {
609 assert_eq!(
610 opts_for_abbrev(true, false, Some(12)).abbrev_len(),
611 Some(12)
612 );
613 }
614}