1use 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)] struct CommitOptions {
58 #[arg(short, long)]
60 message: Option<String>,
61 #[arg(
64 short = 'F',
65 long = "file",
66 value_name = "FILE",
67 conflicts_with = "message"
68 )]
69 file: Option<String>,
70 #[arg(long = "author", value_name = "SPEC")]
72 author_spec: Option<String>,
73 #[arg(short = 'a', long)]
76 all: bool,
77 #[arg(long)]
89 amend: bool,
90 #[arg(short = 'q', long = "quiet")]
92 quiet: bool,
93 #[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 #[arg(long = "no-verify")]
107 no_verify: bool,
108 #[arg(long = "no-edit")]
112 no_edit: bool,
113 #[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 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 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 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 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 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 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 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 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 let _lock = match super::acquire_worktree_lock(&layout) {
277 Ok(l) => l,
278 Err(code) => return code,
279 };
280
281 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 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 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 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 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 let batch = store.batch();
395 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 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 let parents = if let Some(prev) = &amend_target {
428 prev.parents.clone()
429 } else if let Some(state) = &merge_state {
430 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 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 if let Err(e) = batch.commit() {
477 return emit_err(&format!("store commit: {e}"), exit::CANTCREAT);
478 }
479 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 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 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 let old_tree = if is_merge {
538 Some(tree_hash) } 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
587fn 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
600fn 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
658fn 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 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
818fn 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
849fn 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#[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 mkit_core::store::ObjectStore::init(&layout).unwrap();
929 refs::init(&layout).unwrap();
930 (dir, layout)
931 }
932
933 #[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 super::super::write_ref_recording_history(
951 &layout,
952 "main",
953 refs::RefWriteCondition::Missing,
954 &t0,
955 )
956 .unwrap();
957
958 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 #[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 #[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 #[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
1036pub(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
1056fn 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 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
1104fn 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 | 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
1160fn 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 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 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 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"; 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 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}