Skip to main content

mkit_cli/commands/
commit.rs

1//! `mkit commit` — build a signed commit object from the staging
2//! index.
3//!
4//! Scope:
5//! 1. Accept `-m <msg>` OR spawn `$EDITOR` on a tempfile pre-filled
6//! with `editor::COMMIT_EDITMSG_TEMPLATE`. An empty message
7//! aborts.
8//! 2. Read `.mkit/index` and build a tree via
9//! [`worktree::build_tree_from_index`]. An empty / missing index is
10//! an error — `mkit add <path>` (or `mkit add .`) must come first.
11//! 3. Resolve the author identity in this order:
12//! a. `--author <spec>` CLI flag (overrides everything).
13//! b. `config.user_identity` in `.mkit/config`.
14//! c. Derived from the signing key's public key (default).
15//! 4. Sign the commit, write the `Commit` object, advance
16//! `refs/heads/<current>` and `HEAD`.
17//!
18//! Pre-issue-#102 `mkit commit` walked the worktree directly via
19//! `worktree::build_tree`, ignoring the index entirely. That made
20//! `mkit add` write-only state with no reader and surprised any user
21//! reasoning by analogy from git. Post-#102, the staging area is
22//! load-bearing: only paths in the index land in the commit's tree.
23
24use std::io::Write;
25use std::time::{SystemTime, UNIX_EPOCH};
26
27use clap::{Parser, ValueEnum};
28use mkit_core::index;
29use mkit_core::layout::RepoLayout;
30use mkit_core::object::{Commit, Identity, IdentityKind, Object, Tag};
31use mkit_core::ops::conflict_state;
32use mkit_core::refs::{self, Head};
33use mkit_core::serialize;
34use mkit_core::sign::{self, KeyPair};
35use mkit_core::store::ObjectStore;
36use mkit_core::worktree;
37use mkit_keystore::{KeyRef, KeySelector, open_backend};
38
39use crate::clap_shim;
40use crate::config::Config;
41use crate::editor::{COMMIT_EDITMSG_TEMPLATE, spawn_editor};
42use crate::exit;
43use crate::format::{self, JsonObject};
44
45#[derive(Debug, Clone, Copy, ValueEnum)]
46enum CommitFormat {
47    Default,
48    Json,
49}
50
51#[derive(Debug, Parser)]
52#[command(
53    name = "mkit commit",
54    about = "Create a signed commit from the staging index."
55)]
56#[allow(clippy::struct_excessive_bools)] // clap option flags, not a state machine
57struct CommitOptions {
58    /// Commit message. If omitted, `$EDITOR` is launched.
59    #[arg(short, long)]
60    message: Option<String>,
61    /// Read the commit message from `<file>` (like `git commit -F`). Use
62    /// `-` to read from stdin. Mutually exclusive with `-m`.
63    #[arg(
64        short = 'F',
65        long = "file",
66        value_name = "FILE",
67        conflicts_with = "message"
68    )]
69    file: Option<String>,
70    /// Override the author Identity for this commit.
71    #[arg(long = "author", value_name = "SPEC")]
72    author_spec: Option<String>,
73    /// Stage every tracked-and-modified file before committing
74    /// (mirrors `git commit -a`).
75    #[arg(short = 'a', long)]
76    all: bool,
77    /// Replace the current commit (HEAD) instead of adding a new one.
78    ///
79    /// The new commit re-uses HEAD's parent(s) as its own parent(s)
80    /// (so it supersedes HEAD rather than building on it), takes its
81    /// tree from the staging index, and is re-signed. The branch is
82    /// moved to the new commit; the superseded commit becomes
83    /// unreachable. If `-m` is omitted, the previous commit's message
84    /// is reused (no `$EDITOR` is launched).
85    ///
86    /// NOTE: the superseded commit is not deleted — it stays on disk as
87    /// an unreachable object until `mkit gc` ships (see issue #233).
88    #[arg(long)]
89    amend: bool,
90    /// Suppress the commit summary line (git `-q`).
91    #[arg(short = 'q', long = "quiet")]
92    quiet: bool,
93    /// Accepted for git compatibility; mkit ALWAYS signs commits with its
94    /// own key, so `-S`/`--gpg-sign[=<keyid>]` is a no-op (the optional
95    /// `<keyid>` is ignored).
96    #[arg(
97        short = 'S',
98        long = "gpg-sign",
99        value_name = "KEYID",
100        num_args = 0..=1,
101        default_missing_value = ""
102    )]
103    gpg_sign: Option<String>,
104    /// Accepted for git compatibility; mkit has no hooks, so `--no-verify`
105    /// is a no-op.
106    #[arg(long = "no-verify")]
107    no_verify: bool,
108    /// With `--amend`, keep the existing message. mkit already reuses
109    /// HEAD's message when `-m` is omitted, so this is effectively the
110    /// default; accepted for compatibility.
111    #[arg(long = "no-edit")]
112    no_edit: bool,
113    /// Emit a machine-readable JSON result object to stdout on success:
114    /// `{"ok":true,"hash":"<64-hex>","branch":"<name>|null",
115    /// "parents":["<64-hex>",...],"tree":"<64-hex>","subject":"...",
116    /// "is_merge":<bool>,"is_root":<bool>}`.
117    #[arg(long, value_enum, default_value = "default")]
118    format: CommitFormat,
119}
120
121#[must_use]
122#[allow(clippy::too_many_lines)]
123pub fn run(args: &[String]) -> u8 {
124    // Split fused `-am<msg>` / `-am <msg>` shortcuts into the
125    // equivalent `-a -m <msg>` so clap sees only canonical forms.
126    let normalised = expand_dash_am(args);
127    let opts = match clap_shim::parse::<CommitOptions>("mkit commit", &normalised) {
128        Ok(o) => o,
129        Err(code) => return code,
130    };
131    // Accepted-for-compatibility no-ops: mkit always signs (`-S`) and has
132    // no hooks (`--no-verify`); `--no-edit` matches mkit's default amend.
133    let _ = (&opts.gpg_sign, opts.no_verify, opts.no_edit);
134    let json = matches!(opts.format, CommitFormat::Json);
135
136    let cwd = match std::env::current_dir() {
137        Ok(p) => p,
138        Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
139    };
140    let layout = match super::resolve_layout(&cwd) {
141        Ok(layout) => layout,
142        Err(code) => return code,
143    };
144    let store = match super::open_store_configured(&layout) {
145        Ok(s) => s,
146        Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
147    };
148
149    let cfg = match crate::config::read_or_default(&layout) {
150        Ok(c) => c,
151        Err(e) => return emit_err(&format!("config: {e}"), exit::CONFIG_ERROR),
152    };
153
154    // ---- Everything up to the lock acquisition below is read-only and/or
155    // interactive (#641): it composes the commit message — possibly
156    // spawning `$EDITOR`, which can block for an arbitrary, user-paced
157    // amount of time — and loads the signer/key. None of it mutates the
158    // repo, so none of it needs `worktree.lock`. The lock is acquired
159    // just before the actual index/ref write, below, and every read here
160    // whose result is still load-bearing at write time (`merge_state` for
161    // `--amend` compat and for the merge-conclusion path; `pre_lock_head`
162    // for `--amend`) is re-validated immediately after the lock is taken,
163    // so a concurrent write landing during message composition is
164    // detected rather than silently clobbered. See the re-validation
165    // block below for the reasoning on each case, including why a plain
166    // (non-amend, non-merge) commit needs none of this.
167    //
168    // ---- A merge left in progress turns this into a merge commit. --
169    // Either a clean `merge --no-commit`, or a conflicted merge the user
170    // has since resolved and staged. `mkit commit` then records a
171    // two-parent commit and clears the merge state, mirroring how
172    // `git commit` concludes a merge.
173    let merge_state = if conflict_state::is_merge_in_progress(&layout) {
174        match conflict_state::read_merge_state(&layout) {
175            Ok(s) => s,
176            Err(e) => return emit_err(&format!("read merge state: {e}"), exit::GENERAL_ERROR),
177        }
178    } else {
179        None
180    };
181    if merge_state.is_some() && opts.amend {
182        return emit_err(
183            "cannot --amend while a merge is in progress; finish it with `mkit commit` \
184             or abandon it with `mkit merge --abort`",
185            exit::USAGE,
186        );
187    }
188
189    // ---- When amending, load the commit being replaced. ------------
190    // `--amend` re-creates HEAD: the new commit inherits HEAD's parents
191    // (so it supersedes HEAD rather than stacking on it) and, when no
192    // `-m` is given, reuses HEAD's message verbatim.
193    let amend_target = if opts.amend {
194        match resolve_amend_target(&layout, &store) {
195            Ok(commit) => Some(commit),
196            Err((m, c)) => return emit_err(&m, c),
197        }
198    } else {
199        None
200    };
201    // Snapshot of HEAD at the same moment `amend_target` was resolved.
202    // `--amend` reuses that commit's parents (and, absent `-m`, its
203    // message) below, both computed from THIS snapshot rather than a
204    // fresh read at write time — unlike a plain commit's parent, which
205    // is always read fresh under the lock (see `parents` further down).
206    // Message composition + signer loading can now take arbitrarily long
207    // before the lock is acquired, so re-validate after the lock that
208    // HEAD hasn't moved out from under this snapshot; see the
209    // re-validation block right after `acquire_worktree_lock`.
210    let pre_lock_head = if opts.amend {
211        match refs::resolve_head(&layout) {
212            Ok(h) => h,
213            Err(e) => return emit_err(&format!("read HEAD: {e}"), exit::DATAERR),
214        }
215    } else {
216        None
217    };
218
219    // ---- Resolve / prompt for message. -----------------------------
220    // `--amend` without `-m` reuses the superseded commit's message and
221    // never launches `$EDITOR`.
222    // Message precedence: `-m` → `-F <file>` → amend-reuse → merge message
223    // (`MERGE_MSG`) → `$EDITOR`. `-F`/merge defaults never launch the editor
224    // so they stay usable in non-interactive contexts.
225    let msg = match opts.message {
226        Some(m) => m,
227        None => match &opts.file {
228            Some(path) => match read_message_file(path) {
229                Ok(m) if !m.trim().is_empty() => m,
230                Ok(_) => return emit_err("empty commit message — aborting", exit::USAGE),
231                Err(e) => return emit_err(&format!("read message file: {e}"), exit::NOINPUT),
232            },
233            None => match &amend_target {
234                Some(prev) => String::from_utf8_lossy(&prev.message).into_owned(),
235                None => match &merge_state {
236                    Some(state) => String::from_utf8_lossy(&state.message).into_owned(),
237                    None => match spawn_editor(COMMIT_EDITMSG_TEMPLATE) {
238                        Ok(m) if !m.is_empty() => m,
239                        Ok(_) => {
240                            return emit_err("empty commit message — aborting", exit::USAGE);
241                        }
242                        Err(e) => return emit_err(&format!("editor: {e}"), exit::GENERAL_ERROR),
243                    },
244                },
245            },
246        },
247    };
248
249    // ---- Load signer. ----------------------------------------------
250    let mut signer = match load_commit_signer(&layout, &cfg) {
251        Ok(signer) => signer,
252        Err((msg, code)) => return emit_err(&msg, code),
253    };
254    let signer_public = match signer.public_key() {
255        Ok(public) => public,
256        Err((msg, code)) => return emit_err(&msg, code),
257    };
258
259    // ---- Resolve author. -------------------------------------------
260    // Precedence: --author flag → config.user_identity → pubkey-derived.
261    let author = match resolve_author(
262        opts.author_spec.as_deref(),
263        &cfg.user_identity,
264        &signer_public,
265    ) {
266        Ok(id) => id,
267        Err(e) => return emit_err(&format!("author: {e}"), exit::CONFIG_ERROR),
268    };
269
270    // ---- Acquire the write lock. ------------------------------------
271    // Everything above this point (message composition — including any
272    // `$EDITOR` spawn — and signer/key loading) is done. Everything
273    // below mutates the repo (or reads state that must not shift under a
274    // mutation), so it all happens under the lock, right up to the ref
275    // advance.
276    let _lock = match super::acquire_worktree_lock(&layout) {
277        Ok(l) => l,
278        Err(code) => return code,
279    };
280
281    // ---- Re-validate preconditions captured before the lock. --------
282    // `merge_state` and (when `--amend`) `pre_lock_head` were read before
283    // the lock so message composition could use them; re-read them now
284    // and compare, since a concurrent mutator could have run to
285    // completion in the (potentially long, interactive) window between
286    // that read and this lock acquisition.
287    //
288    // `merge_state` unconditionally: it gates the merge-conclusion
289    // checks and the two-parent merge commit below regardless of
290    // `--amend`, so ANY change to it (a merge started, finished, or was
291    // aborted concurrently) must abort rather than act on stale sidecar
292    // state.
293    let fresh_merge_state = if conflict_state::is_merge_in_progress(&layout) {
294        match conflict_state::read_merge_state(&layout) {
295            Ok(s) => s,
296            Err(e) => return emit_err(&format!("read merge state: {e}"), exit::GENERAL_ERROR),
297        }
298    } else {
299        None
300    };
301    if fresh_merge_state != merge_state {
302        return emit_err(
303            "commit aborted: the in-progress merge changed while the commit message was \
304             being composed (concluded or aborted concurrently) — re-run `mkit commit`",
305            exit::TEMPFAIL,
306        );
307    }
308    // `--amend` only: the message-reuse (above) and the parent list
309    // (below) both derive from `amend_target`, which was resolved from
310    // `pre_lock_head`. If HEAD has since moved, that snapshot no longer
311    // describes "the commit being amended" and re-using it would amend
312    // against stale state (and silently orphan whatever now-superseded
313    // commit actually landed).
314    //
315    // A plain (non-amend, non-merge) commit needs NO staleness check: its
316    // parent is read fresh from HEAD below (`refs::resolve_head`, inside
317    // this same lock hold), and its tree is built fresh from the index
318    // read below — both entirely inside the critical section, so there is
319    // no pre-lock snapshot that could go stale. A concurrent commit
320    // landing during message composition just becomes this commit's
321    // parent, exactly as if the two `mkit commit` invocations had run
322    // sequentially.
323    if opts.amend {
324        let fresh_head = match refs::resolve_head(&layout) {
325            Ok(h) => h,
326            Err(e) => return emit_err(&format!("read HEAD: {e}"), exit::DATAERR),
327        };
328        if fresh_head != pre_lock_head {
329            return emit_err(
330                "commit aborted: HEAD changed while the commit message was being composed \
331                 (a concurrent commit landed) — re-run `mkit commit --amend`",
332                exit::TEMPFAIL,
333            );
334        }
335    }
336
337    if opts.all
338        && let Err(e) = super::add::stage_tracked_changes(&layout, &store)
339    {
340        return emit_err(&format!("stage tracked changes: {e}"), exit::GENERAL_ERROR);
341    }
342
343    // Finishing a merge: refuse while conflict markers remain and make sure
344    // every conflicted path is staged, exactly like `mkit merge --continue`
345    // (and `git commit` after a merge). For a clean `merge --no-commit`
346    // the record set is empty, so both checks are no-ops.
347    if merge_state.is_some() {
348        let records = match conflict_state::read_conflicts(layout.worktree_state_dir()) {
349            Ok(r) => r,
350            Err(e) => return emit_err(&format!("read conflicts: {e}"), exit::GENERAL_ERROR),
351        };
352        match super::conflict::first_unresolved_marker(&cwd, &records) {
353            Ok(Some(path)) => {
354                return emit_err(
355                    &format!(
356                        "committing is not possible because '{path}' still has unresolved \
357                         conflict markers; resolve it and `mkit add` it"
358                    ),
359                    exit::GENERAL_ERROR,
360                );
361            }
362            Ok(None) => {}
363            Err(e) => return emit_err(&e, exit::GENERAL_ERROR),
364        }
365        if let Err(e) = super::conflict::ensure_conflict_paths_staged(&layout, &store, &records) {
366            return emit_err(&e, exit::GENERAL_ERROR);
367        }
368    }
369
370    // Read the staging index. An absent file OR a totally empty
371    // entries vector is a hard error — see module docs and issue
372    // #102. An all-Removed index, by contrast, is a meaningful
373    // changeset (the user is committing deletions) and produces an
374    // empty-tree commit, so we DON'T gate on `staged_count()` (which
375    // excludes Removed entries by design).
376    let idx = match index::read_index(&layout) {
377        Ok(idx) => idx,
378        Err(e) => return emit_err(&format!("read index: {e}"), exit::GENERAL_ERROR),
379    };
380    // A merge being concluded may legitimately produce an empty tree (both
381    // sides deleted everything), so the empty-index gate is skipped while a
382    // merge is in progress — the two-parent merge commit is still meaningful.
383    // (A merge that made NO net change vs HEAD is caught below, after the tree
384    // is built, matching git's "nothing to commit".)
385    if idx.entries.is_empty() && merge_state.is_none() {
386        return emit_err(
387            "nothing staged: index is empty; run `mkit add <path>` (or `mkit add .`) before commit",
388            exit::USAGE,
389        );
390    }
391    // One durability batch spans every tree object plus the commit
392    // object; committed below, BEFORE the ref advance that makes the
393    // commit reachable.
394    let batch = store.batch();
395    // Publishing a durable commit — verify staged objects before the tree
396    // references them.
397    let tree_hash = match worktree::build_tree_from_index_with(&store, &batch, &idx, true) {
398        Ok(h) => h,
399        Err(e) => return emit_err(&format!("build tree: {e}"), exit::GENERAL_ERROR),
400    };
401    // Refuse a no-op merge commit produced by discarding the staged merge
402    // (e.g. `reset` between `merge --no-commit` and `commit`), matching git's
403    // "nothing to commit". The staged tree equaling `ORIG_HEAD` is necessary
404    // but NOT sufficient — a legitimate merge of divergent branches can
405    // produce HEAD's tree (e.g. both sides deleted the same file). So only
406    // refuse when the recorded merge RESULT differs from HEAD yet the staged
407    // tree matches it: that means the merge changed something the user then
408    // reverted. (Absent result tree → don't refuse, preserving old behavior.)
409    if let Some(state) = &merge_state
410        && let Ok(Object::Commit(orig)) = store.read_object(&state.orig_head)
411        && orig.tree_hash == tree_hash
412        && conflict_state::read_result_tree(layout.worktree_state_dir())
413            .ok()
414            .flatten()
415            .is_some_and(|result| result != orig.tree_hash)
416    {
417        return emit_err(
418            "nothing to commit: the staged merge was discarded (its result \
419             differs from HEAD but the index matches HEAD); re-stage it or run \
420             `mkit merge --abort`",
421            exit::USAGE,
422        );
423    }
424    // Parent selection. A normal commit builds on HEAD. An `--amend`
425    // replaces HEAD, so it adopts HEAD's *parents* — the superseded
426    // commit drops out of the chain entirely.
427    let parents = if let Some(prev) = &amend_target {
428        prev.parents.clone()
429    } else if let Some(state) = &merge_state {
430        // Two-parent merge commit. Use the merge's recorded base
431        // (`ORIG_HEAD`) as the first parent — NOT the live HEAD — so the
432        // result matches `mkit merge --continue` even if HEAD moved (e.g. a
433        // `reset` between `merge --no-commit` and `commit`), and so we never
434        // depend on a HEAD read that could silently drop a parent.
435        vec![state.orig_head, state.merge_head]
436    } else {
437        match refs::resolve_head(&layout) {
438            Ok(Some(h)) => vec![h],
439            _ => vec![],
440        }
441    };
442    // Capture parent shape before `parents` is moved into the commit, for
443    // the git-shaped summary (root = no parents, merge = >=2 parents) and
444    // the `--format=json` payload.
445    let is_root = parents.is_empty();
446    let is_merge = parents.len() >= 2;
447    let first_parent = parents.first().copied();
448    let parents_for_json = parents.clone();
449    let timestamp = SystemTime::now()
450        .duration_since(UNIX_EPOCH)
451        .map_or(0, |d| d.as_secs());
452    let mut unsigned = Commit::new_unannotated(
453        tree_hash,
454        parents,
455        author,
456        signer_public,
457        msg.as_bytes().to_vec(),
458        timestamp,
459        [0u8; 64],
460    );
461    let sig = match signer.sign_commit(&unsigned) {
462        Ok(s) => s,
463        Err((msg, code)) => return emit_err(&msg, code),
464    };
465    unsigned.signature = sig;
466    let bytes = match serialize::serialize(&Object::Commit(unsigned)) {
467        Ok(b) => b,
468        Err(e) => return emit_err(&format!("serialize commit: {e}"), exit::DATAERR),
469    };
470    let commit_hash = match batch.write(&bytes) {
471        Ok(h) => h,
472        Err(e) => return emit_err(&format!("store commit: {e}"), exit::CANTCREAT),
473    };
474    // Make the tree + commit objects durable before anything (recovery
475    // log, HEAD/branch ref, index) references them.
476    if let Err(e) = batch.commit() {
477        return emit_err(&format!("store commit: {e}"), exit::CANTCREAT);
478    }
479    // Amend supersedes the old HEAD. Record it BEFORE moving the branch
480    // (under the worktree lock) so the superseded commit stays
481    // recoverable; abort if the recovery log can't be written.
482    if amend_target.is_some() {
483        match refs::resolve_head(&layout) {
484            Ok(Some(old_head)) => {
485                let branch = super::head_branch_name(&layout);
486                if let Err((m, c)) = super::record_superseded(&layout, "amend", &branch, old_head) {
487                    return emit_err(&m, c);
488                }
489            }
490            Ok(None) => {}
491            Err(e) => return emit_err(&format!("read HEAD: {e}"), exit::DATAERR),
492        }
493    }
494    // The tip this commit was actually built on, enforced as advance_head's
495    // CAS precondition (issue #658, Fix B) — which value counts as "the
496    // tip" depends on the mode:
497    //
498    // - `--amend`: NOT `parents` (that's the superseded commit's OWN
499    //   parents, one generation further back) — it's `pre_lock_head`,
500    //   already re-validated fresh against HEAD above (the staleness
501    //   check a few dozen lines up), i.e. the commit actually being
502    //   replaced.
503    // - merge-conclusion: NOT `first_parent` (`state.orig_head`) — that's
504    //   deliberately decoupled from live HEAD (see the parent-selection
505    //   comment above), so using it here would let this CAS silently pass
506    //   even when HEAD has moved. Read HEAD fresh, right here, at the
507    //   actual moment of the ref advance.
508    // - plain commit: `first_parent`, which IS a fresh HEAD read (done
509    //   above, inside this same lock hold) — nothing has mutated HEAD
510    //   between that read and here, so no re-read is needed.
511    let expected_tip = if amend_target.is_some() {
512        pre_lock_head
513    } else if merge_state.is_some() {
514        match refs::resolve_head(&layout) {
515            Ok(h) => h,
516            Err(e) => return emit_err(&format!("read HEAD: {e}"), exit::DATAERR),
517        }
518    } else {
519        first_parent
520    };
521    if let Err((m, c)) = advance_head(&layout, &commit_hash, expected_tip) {
522        return emit_err(&m, c);
523    }
524    if let Err(e) = super::sync_index_to_tree(&layout, &store, tree_hash) {
525        return emit_err(&e, exit::CANTCREAT);
526    }
527    // The merge is now recorded; clear MERGE_HEAD/MERGE_MSG/conflicts so the
528    // repo is no longer "merging".
529    if merge_state.is_some()
530        && let Err(e) = conflict_state::clear_merge_state(&layout)
531    {
532        return emit_err(&format!("clear merge state: {e}"), exit::GENERAL_ERROR);
533    }
534    // git-shaped post-commit summary: `[<branch> <hash>] <subject>` plus
535    // a diffstat and create/delete-mode trailers. Merge commits (>=2
536    // parents) show no diffstat, like git.
537    let old_tree = if is_merge {
538        Some(tree_hash) // suppress the diffstat (empty diff) for merges
539    } else {
540        first_parent.and_then(|p| commit_tree(&store, &p))
541    };
542    let branch_name = match refs::read_head(&layout) {
543        Ok(Head::Branch(b)) => Some(b),
544        _ => None,
545    };
546    let head_ref = match &branch_name {
547        Some(b) => super::summary::HeadRef::Branch(b),
548        None => super::summary::HeadRef::Detached,
549    };
550    if !opts.quiet {
551        let mut stderr = std::io::stderr().lock();
552        super::summary::print_commit_summary(
553            &mut stderr,
554            &store,
555            &head_ref,
556            &commit_hash,
557            msg.lines().next().unwrap_or(""),
558            is_root,
559            old_tree,
560            Some(tree_hash),
561        );
562    }
563    if json {
564        let mut obj = JsonObject::new();
565        obj.field_bool("ok", true)
566            .field_hash("hash", &commit_hash)
567            .field_opt_str("branch", branch_name.as_deref())
568            .field_raw(
569                "parents",
570                &format::json_string_array(
571                    &parents_for_json
572                        .iter()
573                        .map(format::hex_hash)
574                        .collect::<Vec<_>>(),
575                ),
576            )
577            .field_hash("tree", &tree_hash)
578            .field_str("subject", msg.lines().next().unwrap_or(""))
579            .field_bool("is_merge", is_merge)
580            .field_bool("is_root", is_root);
581        let mut stdout = std::io::stdout().lock();
582        let _ = writeln!(stdout, "{}", obj.finish());
583    }
584    exit::OK
585}
586
587/// Resolve a commit/remix hash to its tree hash (None on any error or a
588/// non-commit object) — used to bound the post-commit diffstat.
589fn commit_tree(
590    store: &ObjectStore,
591    commit: &mkit_core::hash::Hash,
592) -> Option<mkit_core::hash::Hash> {
593    match store.read_object(commit).ok()? {
594        Object::Commit(c) => Some(c.tree_hash),
595        Object::Remix(r) => Some(r.tree_hash),
596        _ => None,
597    }
598}
599
600/// Pre-process `args` to canonicalize the legacy `-am<msg>` /
601/// `-am <msg>` shortcut into `-a -m <msg>`. Everything else passes
602/// through unchanged. Clap then sees only canonical forms.
603fn expand_dash_am(args: &[String]) -> Vec<String> {
604    let mut out: Vec<String> = Vec::with_capacity(args.len() + 2);
605    let mut iter = args.iter();
606    while let Some(a) = iter.next() {
607        match a.as_str() {
608            "-am" => {
609                out.push("-a".to_owned());
610                out.push("-m".to_owned());
611                if let Some(next) = iter.next() {
612                    out.push(next.clone());
613                }
614            }
615            s if s.starts_with("-am") && s.len() > 3 => {
616                out.push("-a".to_owned());
617                out.push("-m".to_owned());
618                out.push(s[3..].to_owned());
619            }
620            _ => out.push(a.clone()),
621        }
622    }
623    out
624}
625
626#[cfg(test)]
627mod expand_dash_am_tests {
628    use super::expand_dash_am;
629
630    fn to_strs(args: &[String]) -> Vec<&str> {
631        args.iter().map(String::as_str).collect()
632    }
633
634    #[test]
635    fn fused_dash_am_with_inline_message() {
636        let out = expand_dash_am(&["-amhello".to_owned()]);
637        assert_eq!(to_strs(&out), &["-a", "-m", "hello"]);
638    }
639
640    #[test]
641    fn spaced_dash_am_with_following_message() {
642        let out = expand_dash_am(&["-am".to_owned(), "hello".to_owned()]);
643        assert_eq!(to_strs(&out), &["-a", "-m", "hello"]);
644    }
645
646    #[test]
647    fn unrelated_args_pass_through() {
648        let out = expand_dash_am(&[
649            "-m".to_owned(),
650            "msg".to_owned(),
651            "--author".to_owned(),
652            "id".to_owned(),
653        ]);
654        assert_eq!(to_strs(&out), &["-m", "msg", "--author", "id"]);
655    }
656}
657
658/// Load the Ed25519 signing key. Returns a mapped (message,
659/// exit-code) pair on failure so the caller can route the error
660/// through its usual `emit_err` path.
661///
662/// Auto-generation was removed: combined with a non-atomic `save_key`,
663/// an interrupted keygen could silently rotate the user's identity
664/// (subsequent commits no longer share a signer with prior ones). The
665/// save path is now atomic, but auto-keygen also masks genuine
666/// path-misconfigurations and tooling errors. Users run `mkit keygen`
667/// once, explicitly, and a missing key on `mkit commit` is now an error.
668fn load_signing_key(
669    layout: &RepoLayout,
670    rel_signing_key_path: &str,
671) -> Result<KeyPair, (String, u8)> {
672    let key_path = match crate::config::resolve_key_path(layout, rel_signing_key_path) {
673        Ok(p) => p,
674        Err(e) => return Err((format!("{e}"), exit::CONFIG_ERROR)),
675    };
676    if !key_path.exists() {
677        return Err((
678            format!(
679                "no signing key at {} — run `mkit keygen` to create one",
680                key_path.display()
681            ),
682            exit::NOINPUT,
683        ));
684    }
685    sign::load_key(&key_path).map_err(|e| (format!("load key: {e}"), exit::NOPERM))
686}
687
688pub(super) enum CommitSigner {
689    Legacy(KeyPair),
690    Keystore(Box<dyn mkit_keystore::KeySigner>),
691}
692
693impl CommitSigner {
694    pub(super) fn public_key(&self) -> Result<[u8; 32], (String, u8)> {
695        match self {
696            Self::Legacy(kp) => Ok(kp.public.0),
697            Self::Keystore(signer) => {
698                let public = signer
699                    .public_key()
700                    .map_err(|error| (format!("keystore public key: {error}"), exit::DATAERR))?;
701                public.as_bytes().try_into().map_err(|_| {
702                    (
703                        format!(
704                            "keystore Ed25519 public key must be 32 bytes, got {}",
705                            public.len()
706                        ),
707                        exit::DATAERR,
708                    )
709                })
710            }
711        }
712    }
713
714    /// Sign a [`Tag`] under the distinct tag domain. Mirrors
715    /// [`Self::sign_commit`]: legacy keypairs sign directly, keystore
716    /// signers sign the pre-computed tag signing hash.
717    pub(super) fn sign_tag(&mut self, tag: &Tag) -> Result<[u8; 64], (String, u8)> {
718        match self {
719            Self::Legacy(kp) => sign::sign_tag(tag, kp)
720                .map(|signature| signature.0)
721                .map_err(|error| (format!("sign: {error}"), exit::GENERAL_ERROR)),
722            Self::Keystore(signer) => {
723                let digest = sign::tag_signing_hash(tag)
724                    .map_err(|error| (format!("tag signing hash: {error}"), exit::DATAERR))?;
725                let signature = signer
726                    .sign(&digest)
727                    .map_err(|error| (format!("keystore sign: {error}"), exit::DATAERR))?;
728                signature.try_into().map_err(|signature: Vec<u8>| {
729                    (
730                        format!(
731                            "keystore Ed25519 signature must be 64 bytes, got {}",
732                            signature.len()
733                        ),
734                        exit::DATAERR,
735                    )
736                })
737            }
738        }
739    }
740
741    pub(super) fn sign_commit(&mut self, commit: &Commit) -> Result<[u8; 64], (String, u8)> {
742        match self {
743            Self::Legacy(kp) => sign::sign_commit(commit, kp)
744                .map(|signature| signature.0)
745                .map_err(|error| (format!("sign: {error}"), exit::GENERAL_ERROR)),
746            Self::Keystore(signer) => {
747                let digest = sign::commit_signing_hash(commit)
748                    .map_err(|error| (format!("commit signing hash: {error}"), exit::DATAERR))?;
749                let signature = signer
750                    .sign(&digest)
751                    .map_err(|error| (format!("keystore sign: {error}"), exit::DATAERR))?;
752                signature.try_into().map_err(|signature: Vec<u8>| {
753                    (
754                        format!(
755                            "keystore Ed25519 signature must be 64 bytes, got {}",
756                            signature.len()
757                        ),
758                        exit::DATAERR,
759                    )
760                })
761            }
762        }
763    }
764}
765
766pub(super) fn load_commit_signer(
767    layout: &RepoLayout,
768    cfg: &Config,
769) -> Result<CommitSigner, (String, u8)> {
770    match cfg.signer.as_str() {
771        "" | "legacy" => load_signing_key(layout, &cfg.signing_key).map(CommitSigner::Legacy),
772        "keystore" => load_keystore_commit_signer(cfg),
773        other => Err((
774            format!("unknown signer `{other}` — expected `legacy` or `keystore`"),
775            exit::CONFIG_ERROR,
776        )),
777    }
778}
779
780fn load_keystore_commit_signer(cfg: &Config) -> Result<CommitSigner, (String, u8)> {
781    let key_ref = cfg
782        .key
783        .ed25519_ref_or_fallback()
784        .parse::<KeyRef>()
785        .map_err(|error| (format!("key.ed25519_ref: {error}"), exit::CONFIG_ERROR))?;
786    let store = open_backend(key_ref.backend())
787        .map_err(|error| (format!("keystore backend: {error}"), exit::UNAVAILABLE))?;
788    let selector = KeySelector::new(
789        key_ref.label().to_owned(),
790        Some(mkit_keystore::Algorithm::Ed25519),
791    )
792    .map_err(|error| (format!("key.ed25519_ref: {error}"), exit::CONFIG_ERROR))?;
793    let opener = store.opener().ok_or_else(|| {
794        (
795            format!(
796                "keystore backend `{}` does not support opening keys",
797                key_ref.backend()
798            ),
799            exit::DATAERR,
800        )
801    })?;
802    let signer = opener.open(&selector).map_err(|error| match error {
803        mkit_keystore::Error::KeyNotFound(_) => (
804            format!(
805                "missing keystore signing key for algorithm ed25519 — run `mkit key generate --backend {} --algorithm ed25519 --label <label>` first, or set `signer = legacy` and use `mkit keygen`: {error}",
806                key_ref.backend()
807            ),
808            exit::NOINPUT,
809        ),
810        other => (
811            format!("keystore signing key for algorithm ed25519: {other}"),
812            exit::DATAERR,
813        ),
814    })?;
815    Ok(CommitSigner::Keystore(signer))
816}
817
818/// Resolve the commit that `--amend` will replace.
819///
820/// Returns the decoded HEAD [`Commit`]. The new amended commit reuses
821/// this commit's parents and (absent `-m`) its message. Errors when
822/// HEAD has no commit yet (nothing to amend) or when HEAD does not
823/// resolve to a `Commit` object.
824fn resolve_amend_target(layout: &RepoLayout, store: &ObjectStore) -> Result<Commit, (String, u8)> {
825    let head = refs::resolve_head(layout)
826        .map_err(|e| (format!("read HEAD: {e}"), exit::DATAERR))?
827        .ok_or_else(|| {
828            (
829                "nothing to amend: HEAD has no commit yet".to_owned(),
830                exit::USAGE,
831            )
832        })?;
833    match store.read_object(&head) {
834        Ok(Object::Commit(c)) => Ok(c),
835        Ok(_) => Err((
836            format!(
837                "cannot amend: HEAD {} is not a commit",
838                format::hex_hash(&head)
839            ),
840            exit::DATAERR,
841        )),
842        Err(e) => Err((
843            format!("read HEAD commit {}: {e}", format::hex_hash(&head)),
844            exit::DATAERR,
845        )),
846    }
847}
848
849/// Advance the branch pointed to by HEAD (or HEAD itself, if detached)
850/// to `commit_hash`.
851///
852/// Routes through [`super::write_ref_recording_history`] so a build
853/// with `--features history-mmr` records every advance in the branch's
854/// journaled MMR under the repo's `refs-history.lock`. Detached HEAD
855/// advances bypass the journal: per-branch history is keyed on a
856/// branch name and a detached HEAD has none.
857///
858/// `expected` is the tip this commit was actually built on top of —
859/// `Some(parent)` for a normal advance, `None` for a root commit or an
860/// unborn branch's first commit — and is enforced as a CAS precondition
861/// (issue #658, Fix B): `Some(t)` becomes `RefWriteCondition::Match(t)`,
862/// `None` becomes `RefWriteCondition::Missing`. Before this, the
863/// branch-ref advance used `RefWriteCondition::Any`, an unconditional
864/// clobber: a concurrent writer (e.g. `branch -m` publishing a stale
865/// pre-commit tip under a new name, or another `commit`) could land
866/// between this commit composing its parent and this call executing,
867/// and `Any` would still "succeed" — silently discarding whichever
868/// commit didn't win, with no error to either side. See `run`'s
869/// call site for how `expected` is derived per commit mode (plain,
870/// `--amend`, merge-conclusion).
871fn advance_head(
872    layout: &RepoLayout,
873    commit_hash: &mkit_core::hash::Hash,
874    expected: Option<mkit_core::hash::Hash>,
875) -> Result<(), (String, u8)> {
876    let head = refs::read_head(layout).map_err(|e| (format!("read HEAD: {e}"), exit::DATAERR))?;
877    match head {
878        Head::Branch(name) => {
879            let condition = match expected {
880                Some(h) => refs::RefWriteCondition::Match(h),
881                None => refs::RefWriteCondition::Missing,
882            };
883            super::write_ref_recording_history(layout, &name, condition, commit_hash).map_err(|e| {
884                match e {
885                    refs::RefError::Conflict(_) => (
886                        format!(
887                            "commit aborted: branch '{name}' moved underneath this commit \
888                             (a concurrent commit landed) — the commit object {} is durable \
889                             but currently unreferenced (GC-recoverable), nothing is corrupted; \
890                             re-run `mkit commit`",
891                            format::hex_hash(commit_hash)
892                        ),
893                        exit::TEMPFAIL,
894                    ),
895                    other => (format!("write ref: {other}"), exit::CANTCREAT),
896                }
897            })
898        }
899        Head::Detached(_) => refs::write_head_detached(layout, commit_hash)
900            .map_err(|e| (format!("update HEAD: {e}"), exit::CANTCREAT)),
901    }
902}
903
904/// Issue #658, Fix B — direct, deterministic tests of `advance_head`'s
905/// CAS enforcement. These exercise the mechanism itself (does it
906/// translate `expected` into the right [`refs::RefWriteCondition`], and
907/// does a mismatch surface as `TEMPFAIL` rather than a silent clobber)
908/// without depending on timing to reproduce a live cross-process race —
909/// `branch_rename_commit_race.rs` (a `mkit-cli` integration test) covers
910/// the genuine racing scenario end-to-end.
911#[cfg(test)]
912mod advance_head_tests {
913    use super::*;
914    use mkit_core::hash::hash;
915    use mkit_core::layout::RepoLayout;
916    use tempfile::TempDir;
917
918    fn fresh_repo() -> (TempDir, RepoLayout) {
919        let dir = TempDir::new().unwrap();
920        let layout = RepoLayout::single(dir.path());
921        // On `--features history-mmr` builds, `advance_head` routes
922        // through `write_ref_recording_history`, which opens the object
923        // store (for the empty-journal backfill's `parent_of` walker)
924        // even though these tests never actually need a commit object
925        // read. `ObjectStore::init` (which also creates the common dir
926        // — it errors if the dir already exists) must run BEFORE
927        // `refs::init` for exactly that reason.
928        mkit_core::store::ObjectStore::init(&layout).unwrap();
929        refs::init(&layout).unwrap();
930        (dir, layout)
931    }
932
933    /// The core Fix B regression: if the branch moved to a value other
934    /// than `expected` since the caller captured it (a concurrent
935    /// writer landed in the window between `run`'s parent read and this
936    /// call), the advance must refuse — `TEMPFAIL`, matching the
937    /// existing amend-staleness error's tone — and the concurrently
938    /// landed value must survive completely untouched.
939    #[test]
940    fn advance_head_conflicts_when_branch_moved_since_expected_was_captured() {
941        let (_dir, layout) = fresh_repo();
942        let t0 = hash(b"t0");
943        // Seeded via the same `write_ref_recording_history` helper
944        // `advance_head` itself uses (not a raw `refs::write_ref`): on
945        // `--features history-mmr` builds a bare ref write with no
946        // journal entry makes the NEXT history-aware write try to
947        // backfill from `t0` as a real commit object, which it isn't
948        // here. `Missing` establishes a proper from-empty journal
949        // instead, matching how a real first commit would seed it.
950        super::super::write_ref_recording_history(
951            &layout,
952            "main",
953            refs::RefWriteCondition::Missing,
954            &t0,
955        )
956        .unwrap();
957
958        // A concurrent writer (e.g. another commit, or `update-ref`)
959        // advances "main" past what this commit's `expected` snapshot
960        // (`t0`) describes.
961        let moved = hash(b"moved-concurrently");
962        super::super::write_ref_recording_history(
963            &layout,
964            "main",
965            refs::RefWriteCondition::Match(t0),
966            &moved,
967        )
968        .unwrap();
969
970        let new_commit = hash(b"new-commit");
971        let (msg, code) = advance_head(&layout, &new_commit, Some(t0)).unwrap_err();
972        assert_eq!(code, exit::TEMPFAIL);
973        assert!(
974            msg.contains("moved") && msg.contains("commit aborted"),
975            "expected a clear conflict message, got: {msg}"
976        );
977        assert_eq!(
978            refs::read_ref(&layout, "main").unwrap(),
979            Some(moved),
980            "the concurrently-landed value must survive a refused advance untouched"
981        );
982    }
983
984    /// Normal case: `expected` matches the ref's current value, so the
985    /// `Match` CAS succeeds and the branch advances.
986    #[test]
987    fn advance_head_succeeds_when_expected_matches_current_value() {
988        let (_dir, layout) = fresh_repo();
989        let t0 = hash(b"t0");
990        super::super::write_ref_recording_history(
991            &layout,
992            "main",
993            refs::RefWriteCondition::Missing,
994            &t0,
995        )
996        .unwrap();
997
998        let c1 = hash(b"c1");
999        advance_head(&layout, &c1, Some(t0)).unwrap();
1000        assert_eq!(refs::read_ref(&layout, "main").unwrap(), Some(c1));
1001    }
1002
1003    /// Root commit / unborn-branch case: `expected = None` becomes
1004    /// `RefWriteCondition::Missing`, which succeeds when the branch has
1005    /// no ref yet.
1006    #[test]
1007    fn advance_head_missing_condition_succeeds_for_a_fresh_branch() {
1008        let (_dir, layout) = fresh_repo();
1009        let c1 = hash(b"root-commit");
1010        advance_head(&layout, &c1, None).unwrap();
1011        assert_eq!(refs::read_ref(&layout, "main").unwrap(), Some(c1));
1012    }
1013
1014    /// If a concurrent writer raced to create the branch first (e.g.
1015    /// another root commit landed), `Missing` must refuse rather than
1016    /// silently overwrite it.
1017    #[test]
1018    fn advance_head_missing_condition_conflicts_when_branch_already_exists() {
1019        let (_dir, layout) = fresh_repo();
1020        let raced_in = hash(b"raced-in-first");
1021        super::super::write_ref_recording_history(
1022            &layout,
1023            "main",
1024            refs::RefWriteCondition::Missing,
1025            &raced_in,
1026        )
1027        .unwrap();
1028
1029        let c1 = hash(b"root-commit");
1030        let (_, code) = advance_head(&layout, &c1, None).unwrap_err();
1031        assert_eq!(code, exit::TEMPFAIL);
1032        assert_eq!(refs::read_ref(&layout, "main").unwrap(), Some(raced_in));
1033    }
1034}
1035
1036/// Resolve the commit author. See [`run`] for precedence order.
1037///
1038/// Exposed to sibling commands (`cherry_pick`, `merge`) so they apply
1039/// the same precedence as `commit`: `--author` flag (if any) → user-
1040/// scoped `user.identity` config → signer pubkey fallback. They pass
1041/// `None` for `author_flag` because they don't accept that flag.
1042pub(super) fn resolve_author(
1043    author_flag: Option<&str>,
1044    cfg_user_identity: &str,
1045    signer_public: &[u8; 32],
1046) -> Result<Identity, String> {
1047    if let Some(spec) = author_flag {
1048        return parse_author_spec(spec);
1049    }
1050    if !cfg_user_identity.is_empty() {
1051        return decode_user_identity_hex(cfg_user_identity);
1052    }
1053    Ok(Identity::ed25519(*signer_public))
1054}
1055
1056/// Parse a `--author` flag value.
1057///
1058/// Accepted forms:
1059/// * `ed25519:<64-char hex>` — 32-byte Ed25519 public key.
1060/// * `did:key:<multibase>` — a `did:key` whose multibase payload (the part
1061///   after `did:key:`, e.g. `z6Mk…`) is stored verbatim as the DID payload.
1062///   It must be a non-empty printable-ASCII multibase string (validated via
1063///   `Identity::is_valid`), matching the on-disk `DidKey` invariant.
1064/// * `opaque:<bytes>` — raw UTF-8 bytes, stored as-is.
1065fn parse_author_spec(spec: &str) -> Result<Identity, String> {
1066    if let Some(hex) = spec.strip_prefix("ed25519:") {
1067        let bytes = hex_decode(hex).ok_or_else(|| "ed25519:<hex> invalid hex".to_string())?;
1068        if bytes.len() != 32 {
1069            return Err("ed25519:<hex> must decode to 32 bytes".to_string());
1070        }
1071        let mut arr = [0u8; 32];
1072        arr.copy_from_slice(&bytes);
1073        return Ok(Identity::ed25519(arr));
1074    }
1075    if let Some(payload) = spec.strip_prefix("did:key:") {
1076        // Store the multibase payload verbatim (the `did:key:` scheme prefix
1077        // is stripped). A real did:key is base58btc (`z…`); the on-disk
1078        // invariant only requires a non-empty printable-ASCII multibase
1079        // string, so validate through `is_valid` rather than hex-decoding.
1080        let id = Identity {
1081            kind: IdentityKind::DidKey,
1082            bytes: payload.as_bytes().to_vec(),
1083        };
1084        if !id.is_valid() {
1085            return Err(
1086                "did:key:<multibase> must be a non-empty printable-ASCII multibase string \
1087                 (e.g. did:key:z6Mk…)"
1088                    .to_string(),
1089            );
1090        }
1091        return Ok(id);
1092    }
1093    if let Some(raw) = spec.strip_prefix("opaque:") {
1094        if raw.is_empty() {
1095            return Err("opaque:<bytes> must not be empty".to_string());
1096        }
1097        return Ok(Identity::opaque(raw.as_bytes().to_vec()));
1098    }
1099    Err(format!(
1100        "unknown identity spec '{spec}' — expected ed25519:<hex>, did:key:<multibase>, or opaque:<bytes>"
1101    ))
1102}
1103
1104/// Decode a `user.identity` config string into an [`Identity`]. The
1105/// config file stores the canonical `[kind:u8][len:u16 LE][bytes]`
1106/// form (see `config::expand_user_identity`), so we invert that here.
1107fn decode_user_identity_hex(hex: &str) -> Result<Identity, String> {
1108    let bytes =
1109        hex_decode(hex).ok_or_else(|| "user.identity: not a lowercase hex string".to_string())?;
1110    if bytes.len() < 3 {
1111        return Err("user.identity: too short (kind + len prefix missing)".to_string());
1112    }
1113    let kind_byte = bytes[0];
1114    let declared_len = u16::from(bytes[1]) | (u16::from(bytes[2]) << 8);
1115    if bytes.len() != usize::from(declared_len) + 3 {
1116        return Err("user.identity: declared length does not match payload".to_string());
1117    }
1118    let payload = bytes[3..].to_vec();
1119    let kind = match kind_byte {
1120        0x01 => IdentityKind::Ed25519,
1121        0x02 => IdentityKind::DidKey,
1122        // 0x03 (mid) shares the Opaque variant — upstream compat.
1123        0x03 | 0x04 => IdentityKind::Opaque,
1124        other => return Err(format!("user.identity: unknown kind byte {other:#04x}")),
1125    };
1126    if kind == IdentityKind::Ed25519 && payload.len() != 32 {
1127        return Err("user.identity: ed25519 payload must be exactly 32 bytes".to_string());
1128    }
1129    Ok(Identity {
1130        kind,
1131        bytes: payload,
1132    })
1133}
1134
1135fn hex_decode(s: &str) -> Option<Vec<u8>> {
1136    if !s.len().is_multiple_of(2) {
1137        return None;
1138    }
1139    let mut out = Vec::with_capacity(s.len() / 2);
1140    let b = s.as_bytes();
1141    let mut i = 0;
1142    while i < b.len() {
1143        let hi = nibble(b[i])?;
1144        let lo = nibble(b[i + 1])?;
1145        out.push((hi << 4) | lo);
1146        i += 2;
1147    }
1148    Some(out)
1149}
1150
1151fn nibble(c: u8) -> Option<u8> {
1152    Some(match c {
1153        b'0'..=b'9' => c - b'0',
1154        b'a'..=b'f' => 10 + c - b'a',
1155        b'A'..=b'F' => 10 + c - b'A',
1156        _ => return None,
1157    })
1158}
1159
1160/// Read a `-F`/`--file` commit message. `-` reads stdin; otherwise the
1161/// named file. Trailing whitespace is trimmed (git's default `-F` cleanup
1162/// drops trailing blank lines).
1163fn read_message_file(path: &str) -> std::io::Result<String> {
1164    use std::io::Read as _;
1165    let raw = if path == "-" {
1166        let mut s = String::new();
1167        std::io::stdin().lock().read_to_string(&mut s)?;
1168        s
1169    } else {
1170        std::fs::read_to_string(path)?
1171    };
1172    Ok(raw.trim_end().to_string())
1173}
1174
1175use super::error as emit_err;
1176
1177#[cfg(test)]
1178mod tests {
1179    use super::*;
1180    use mkit_keystore::Keystore;
1181
1182    #[test]
1183    fn parse_author_ed25519_roundtrips() {
1184        let hex = "11".repeat(32);
1185        let spec = format!("ed25519:{hex}");
1186        let id = parse_author_spec(&spec).unwrap();
1187        assert_eq!(id.kind, IdentityKind::Ed25519);
1188        assert_eq!(id.bytes.len(), 32);
1189        assert!(id.bytes.iter().all(|&b| b == 0x11));
1190    }
1191
1192    #[test]
1193    fn parse_author_rejects_bad_ed25519() {
1194        assert!(parse_author_spec("ed25519:short").is_err());
1195        assert!(parse_author_spec("ed25519:zzzzz").is_err());
1196    }
1197
1198    #[test]
1199    fn parse_author_did_key_stores_multibase_payload() {
1200        // The multibase payload after `did:key:` is stored verbatim as ASCII.
1201        let id = parse_author_spec("did:key:z6MkExample").unwrap();
1202        assert_eq!(id.kind, IdentityKind::DidKey);
1203        assert_eq!(id.bytes, b"z6MkExample");
1204        assert!(id.is_valid());
1205    }
1206
1207    #[test]
1208    fn parse_author_did_key_rejects_non_multibase() {
1209        // Empty payload and non-printable/whitespace payloads are rejected
1210        // (consistent with the on-disk DidKey invariant).
1211        assert!(parse_author_spec("did:key:").is_err());
1212        assert!(parse_author_spec("did:key:has space").is_err());
1213    }
1214
1215    #[test]
1216    fn parse_author_opaque_takes_raw_bytes() {
1217        let id = parse_author_spec("opaque:hello world").unwrap();
1218        assert_eq!(id.kind, IdentityKind::Opaque);
1219        assert_eq!(id.bytes, b"hello world");
1220    }
1221
1222    #[test]
1223    fn parse_author_rejects_unknown_prefix() {
1224        assert!(parse_author_spec("foo:bar").is_err());
1225        assert!(parse_author_spec("").is_err());
1226    }
1227
1228    #[test]
1229    fn decode_user_identity_ed25519_roundtrip() {
1230        // Mirror expand_user_identity("ed25519:<hex>") output.
1231        // 0x01 + len(32=0x20,0x00) + 32 bytes of 0xAB.
1232        let mut hex = String::from("012000");
1233        hex.push_str(&"ab".repeat(32));
1234        let id = decode_user_identity_hex(&hex).unwrap();
1235        assert_eq!(id.kind, IdentityKind::Ed25519);
1236        assert_eq!(id.bytes.len(), 32);
1237    }
1238
1239    #[test]
1240    fn decode_user_identity_rejects_length_mismatch() {
1241        let hex = "011000aabbcc"; // declares 16 bytes, provides 3
1242        assert!(decode_user_identity_hex(hex).is_err());
1243    }
1244
1245    #[test]
1246    fn resolve_author_prefers_flag_over_config() {
1247        let kp = KeyPair::generate().unwrap();
1248        let hex = "22".repeat(32);
1249        let spec = format!("ed25519:{hex}");
1250        // Populate config with a DIFFERENT identity to verify flag wins.
1251        let cfg_hex = {
1252            let mut s = String::from("012000");
1253            s.push_str(&"33".repeat(32));
1254            s
1255        };
1256        let id = resolve_author(Some(&spec), &cfg_hex, &kp.public.0).unwrap();
1257        assert!(id.bytes.iter().all(|&b| b == 0x22));
1258    }
1259
1260    #[test]
1261    fn resolve_author_uses_config_when_no_flag() {
1262        let kp = KeyPair::generate().unwrap();
1263        let mut cfg_hex = String::from("012000");
1264        cfg_hex.push_str(&"44".repeat(32));
1265        let id = resolve_author(None, &cfg_hex, &kp.public.0).unwrap();
1266        assert_eq!(id.kind, IdentityKind::Ed25519);
1267        assert!(id.bytes.iter().all(|&b| b == 0x44));
1268    }
1269
1270    #[test]
1271    fn resolve_author_falls_back_to_pubkey() {
1272        let kp = KeyPair::generate().unwrap();
1273        let id = resolve_author(None, "", &kp.public.0).unwrap();
1274        assert_eq!(id.kind, IdentityKind::Ed25519);
1275        assert_eq!(id.bytes, kp.public.0.to_vec());
1276    }
1277
1278    #[test]
1279    fn keystore_commit_signature_matches_legacy_keypair_signature() {
1280        let seed = [0x5a; 32];
1281        let kp = KeyPair::from_seed(seed);
1282        let store_root = tempfile::tempdir().unwrap();
1283        let store = mkit_keystore::SoftwareRawKeystore::with_root(store_root.path().join("keys"));
1284        store
1285            .importer()
1286            .unwrap()
1287            .import(
1288                &mkit_keystore::KeyLabel::new("committer").unwrap(),
1289                mkit_keystore::SecretKey::new(mkit_keystore::Algorithm::Ed25519, seed),
1290                mkit_keystore::KeyAttrs::default(),
1291                mkit_keystore::ImportOptions::default(),
1292            )
1293            .unwrap();
1294        let selector =
1295            mkit_keystore::KeySelector::new("committer", Some(mkit_keystore::Algorithm::Ed25519))
1296                .unwrap();
1297        let mut signer = CommitSigner::Keystore(store.opener().unwrap().open(&selector).unwrap());
1298        let signer_public = signer.public_key().unwrap();
1299        let commit = Commit::new_unannotated(
1300            [1; 32],
1301            vec![[2; 32]],
1302            Identity::ed25519(signer_public),
1303            signer_public,
1304            b"same commit".to_vec(),
1305            123,
1306            [0; 64],
1307        );
1308
1309        let keystore_sig = signer.sign_commit(&commit).unwrap();
1310        let legacy_sig = sign::sign_commit(&commit, &kp).unwrap().0;
1311        assert_eq!(keystore_sig, legacy_sig);
1312    }
1313}