Skip to main content

mkit_cli/commands/
log.rs

1//! `mkit log [<rev>] [<A>..<B> | <A>...<B>]` — walk commit history.
2//!
3//! With no argument the walk starts at `HEAD`. A single `<rev>` starts there
4//! instead; a range `A..B` shows commits reachable from `B` but not `A`
5//! (empty side = `HEAD`, so `A..` is `A..HEAD` and `..B` is `HEAD..B`).
6//! An `A...B` symmetric range shows commits reachable from `A` or `B` but not
7//! their common ancestors (the merge base). Commits are ordered
8//! reverse-chronologically with a topological tie-break (a parent never
9//! precedes a child) — git's `--date-order`. This is identical to git's
10//! default for linear history and monotonic-timestamp merges; it can differ
11//! only on merge DAGs with non-monotonic (skewed or imported) timestamps.
12//!
13//! Output modes:
14//!
15//! - default — human-oriented multi-line per commit on stdout. The
16//!   full commit message body is printed indented (four spaces) and the
17//!   timestamp is rendered as a stable UTC date
18//!   (`YYYY-MM-DD HH:MM:SS +0000`), not the raw integer.
19//! - `--oneline` — `<abbrev-hex> <title>` per commit on stdout. The
20//!   abbreviation length defaults to 7 (`DEFAULT_ABBREV`) and is
21//!   overridable with `--abbrev[=N]`.
22//! - `--format=json` — JSONL, one self-contained JSON object per
23//!   commit. Suitable for piping into `jq`.
24//!
25//! `--graph` is accepted for git compatibility but is a no-op: it is a
26//! documented v1 non-goal (see `docs/CLI.md`). Full graph parity is not
27//! achievable given mkit's content-addressed model; a limited
28//! `--oneline --graph` renderer remains a possible post-v1 follow-up.
29//!
30//! History filters — `--author`/`--grep` (substring matches),
31//! `--since`/`--until` (a small explicit date grammar, see the
32//! `dateparse` submodule), and `--no-merges`/`--first-parent` — are
33//! applied to the walk before `-n`'s limit, so the limit caps the
34//! filtered result like git's does. `--first-parent` prunes the *walk*
35//! itself (a merged side branch never enters the candidate set);
36//! `--no-merges` only hides merge commits from the already-walked
37//! output.
38//!
39//! Argument parsing is delegated to clap-derive via
40//! [`crate::clap_shim::parse`]; clap emits standard diagnostics on
41//! errors and the shim maps them to mkit sysexits (`USAGE` for
42//! unknown flags, `DATAERR` for malformed `-n` values, etc.).
43
44use 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
65/// Default abbreviated-hash length, matching git's nominal `core.abbrev`
66/// starting point. Overridable with `--abbrev[=N]`.
67const 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)] // clap option flags, not a state machine
84struct LogOpts {
85    /// Compact one-line-per-commit output. Equivalent to
86    /// `--format=oneline`; if both are given, `--format` wins.
87    #[arg(long)]
88    oneline: bool,
89
90    /// Output format.
91    #[arg(long, value_enum)]
92    format: Option<Format>,
93
94    /// Cap the number of commits printed.
95    #[arg(short = 'n')]
96    limit: Option<usize>,
97
98    /// Abbreviate commit hashes in the default format (implied by
99    /// `--oneline`).
100    #[arg(long = "abbrev-commit")]
101    abbrev_commit: bool,
102
103    /// Minimum length of abbreviated hashes. Bare `--abbrev` uses the
104    /// default (7); `--abbrev=N` sets the length.
105    #[arg(long, value_name = "N", num_args = 0..=1, default_missing_value = "7")]
106    abbrev: Option<usize>,
107
108    /// Render an ASCII graph. Accepted for git compatibility but a
109    /// no-op (documented v1 non-goal).
110    #[arg(long)]
111    graph: bool,
112
113    /// Only show commits whose author identity contains `<pattern>`
114    /// (substring match against both the short display form — `mkit
115    /// log`'s `Author:` line — and the full `kind:hex`/`mid:N` form used
116    /// by `--format=json`'s `author` field). Unlike git, mkit identities
117    /// are opaque (Ed25519 keys, `mid:N` numbers, DID keys) rather than
118    /// free-text `Name <email>`, so this is a plain substring match, not
119    /// a regex.
120    #[arg(long, value_name = "PATTERN")]
121    author: Option<String>,
122
123    /// Only show commits whose message (title + body) contains
124    /// `<pattern>` (substring match, case-sensitive — like git's default
125    /// `--grep`).
126    #[arg(long, value_name = "PATTERN")]
127    grep: Option<String>,
128
129    /// Only show commits at or after this time. Accepts `@<unix-seconds>`,
130    /// `now`/`today`/`yesterday`, `<N> <unit> ago`
131    /// (second/minute/hour/day/week/month/year), `YYYY-MM-DD`, or
132    /// `YYYY-MM-DD HH:MM:SS` (UTC).
133    #[arg(long, value_name = "DATE")]
134    since: Option<String>,
135
136    /// Only show commits at or before this time. Same formats as
137    /// `--since`.
138    #[arg(long, value_name = "DATE")]
139    until: Option<String>,
140
141    /// Hide merge commits (more than one parent) from the output. The
142    /// walk itself is unchanged — this only filters what gets printed —
143    /// like `git log --no-merges`.
144    #[arg(long = "no-merges")]
145    no_merges: bool,
146
147    /// Follow only the first parent at each merge, so a merged side
148    /// branch never enters the walk at all (stronger than `--no-merges`,
149    /// which still walks through merges but hides them from the
150    /// output). Like `git log --first-parent`.
151    #[arg(long = "first-parent")]
152    first_parent: bool,
153
154    /// Optional starting revision (`<rev>`), range (`A..B`, `A..`, `..B`), or
155    /// symmetric range (`A...B`). Defaults to `HEAD`; an empty range side
156    /// means `HEAD`.
157    start: Option<String>,
158}
159
160impl LogOpts {
161    /// Resolve `(oneline, format)` into the single `Format` the
162    /// renderer consumes. Explicit `--format` wins over `--oneline`.
163    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    /// Abbreviation length for commit ids, or `None` to print the full
172    /// 64-hex hash. `--abbrev=N` sets the length (and implies
173    /// abbreviation); `--abbrev-commit` (or the `Oneline` format)
174    /// abbreviates at `DEFAULT_ABBREV`. `short_hash` clamps the length
175    /// to `[4, 64]`, so out-of-range `N` is harmless.
176    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; // accepted, currently no-op.
196
197    // `--since`/`--until` are validated up front (a bad date is a usage
198    // error, not a silent no-match) before touching the repository.
199    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    // Resolve the revision selection: default HEAD, a single `<rev>`, a
230    // `A..B` range, or an `A...B` symmetric range (empty side = HEAD).
231    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            // No HEAD yet and no explicit revision → nothing to show.
236            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    // `-n <limit>` caps the *filtered* set, not the raw walk, so it
252    // composes with `--author`/`--grep`/`--since`/`--until`/`--no-merges`
253    // the way git's does.
254    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
279/// Does `c` pass every active `log` filter? `since`/`until` are already
280/// resolved Unix seconds; `author`/`grep` are substring patterns.
281fn 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
313/// The include tips to walk plus the excluded ancestor set, resolved from a
314/// [`RevSelection`].
315type WalkSet = (Vec<Hash>, HashSet<Hash>);
316
317/// A parsed `log` revision selection.
318enum RevSelection {
319    /// No argument → walk `HEAD`.
320    Default,
321    /// A single `<rev>` → walk its history.
322    Single(String),
323    /// `A..B` → reachable from `B` but not `A`.
324    Range { exclude: String, include: String },
325    /// `A...B` → reachable from `A` or `B` but not their common ancestors.
326    Symmetric { a: String, b: String },
327}
328
329/// Parse the optional `<rev>` / `A..B` / `A...B` positional. An empty range
330/// side resolves to `HEAD`.
331fn 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    // Check `...` before `..` since the former contains the latter.
343    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
358/// Resolve a [`RevSelection`] into the set of include tips to walk and the
359/// excluded ancestor set. `Ok(None)` means there is nothing to show (e.g. a
360/// HEAD-less repo with no explicit revision).
361fn 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            // Exclude the common ancestors (ancestors of the merge base).
390            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
407/// Resolve a tip spec to a commit hash. `None` spec = HEAD (which may be
408/// absent → `Ok(None)`). An explicit spec that fails to resolve is an error.
409/// The resolved hash is peeled through annotated/signed tag objects so
410/// `log <tag>` / `<tag>..HEAD` walk the tagged commit, like git.
411fn 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
426/// Maximum tag-of-tag chain length to follow when peeling (cycle guard).
427const MAX_TAG_DEPTH: usize = 16;
428
429/// Follow `Object::Tag` targets to the first non-tag object, so an
430/// annotated/signed tag resolves to the commit it points at. A non-tag (or
431/// unreadable) object stops the peel and is returned as-is. Shared with
432/// `rev-list` / `merge-base`.
433pub(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
443/// A commit ready to emit, ordered by timestamp (newest first) with the hash
444/// as a deterministic tiebreak.
445struct 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
469/// Hard cap on commits collected for one `log` invocation.
470const MAX_LOG_COMMITS: usize = 1_000_000;
471
472/// Collect the commits reachable from any of `tips` (minus `excluded`) in
473/// git's `--date-order`: reverse-chronological by commit timestamp, with a
474/// parent never shown before any of its children (topological tie-break). Uses
475/// an in-degree + max-heap revwalk so equal-timestamp linear history keeps its
476/// natural child→parent order. Matches git's *default* order for linear and
477/// monotonic-timestamp history.
478/// Topologically-ordered (reverse-chronological) commit walk from `tips`,
479/// excluding `excluded` and their ancestors. Shared with `rev-list`.
480/// Equivalent to [`ordered_commits_opts`] with `first_parent: false`.
481pub(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
489/// Like [`ordered_commits`] but with `--first-parent` control: when
490/// `first_parent` is set, the candidate-collection walk follows only each
491/// commit's first parent, so a merge's later parents — and anything only
492/// reachable through them — never enter the candidate set at all. Matches
493/// git's `log --first-parent` (stronger than `--no-merges`, which still
494/// walks through merges and only hides them from the printed list).
495pub(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    // 1. Collect the candidate commit set (DFS over parents, skip excluded).
502    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    // 2. In-degree = number of children within the candidate set.
540    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    // 3. Max-heap (by timestamp) over commits whose children are all emitted.
550    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
578/// Render one commit in the selected format.
579fn 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            // Full message body, indented like git. Each line is prefixed
603            // with four spaces; blank lines stay blank.
604            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
619/// Emit one JSONL record for a commit. Schema:
620///
621/// ```json
622/// {
623///   "hash": "<64-hex>",
624///   "parents": ["<64-hex>", ...],
625///   "tree": "<64-hex>",
626///   "author": "<identity-string>",
627///   "timestamp": <unix-seconds>,
628///   "title": "<first line of message>",
629///   "message": "<full message, JSON-escaped>"
630/// }
631/// ```
632///
633/// Keys are written in a deterministic order so the output is
634/// reproducible and easy to snapshot-test.
635fn 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        // Without --no-merges, merges still pass.
880        assert!(commit_matches(&merge, None, None, None, None, false));
881    }
882}