1use std::collections::{HashMap, HashSet};
18use std::fmt::Write as _;
19use std::io::Write;
20use std::path::{Path, PathBuf};
21
22use clap::Parser;
23use mkit_core::Hash;
24use mkit_core::layout::RepoLayout;
25use mkit_core::object::{Commit, Object};
26use mkit_core::store::ObjectStore;
27use mkit_git_bridge::gitobj::{GitObject, GitType, Sha1Id, sha1_hex};
28use mkit_git_bridge::gitsrc::{CatFileBatch, GitObjKind};
29use mkit_git_bridge::verify::ShallowVerdict;
30use mkit_git_bridge::{author, gitparse, map, reconstruct, translate, verify};
31
32use super::revspec;
33use crate::exit;
34
35type CmdResult<T> = Result<T, (String, u8)>;
36
37const ATTESTATIONS_REF: &str = "refs/mkit/attestations";
38
39#[derive(Debug, Parser)]
40pub(super) struct VerifyArgs {
41 #[arg(long = "remote-name", value_name = "NAME")]
44 pub remote_name: Option<String>,
45 #[arg(long = "fork-audit")]
49 pub fork_audit: bool,
50 #[arg(long = "ref", value_name = "REF")]
53 pub refs: Vec<String>,
54}
55
56#[derive(Debug, Parser)]
57pub(super) struct StatusArgs {}
58
59#[derive(Debug, Parser)]
60pub(super) struct FormatPatchArgs {
61 pub range: String,
63 #[arg(short = 'o', long = "output-directory", value_name = "DIR")]
65 pub output: Option<PathBuf>,
66 #[arg(long)]
68 pub stdout: bool,
69}
70
71fn state_names(layout: &RepoLayout) -> Vec<String> {
74 let mut names = Vec::new();
75 if let Ok(rd) = std::fs::read_dir(layout.git_state_dir()) {
76 for e in rd.flatten() {
77 if e.path().is_dir()
78 && let Some(name) = e.file_name().to_str()
79 && !name.starts_with('.')
86 {
87 names.push(name.to_owned());
88 }
89 }
90 }
91 names.sort();
92 names
93}
94
95fn resolve_state(layout: &RepoLayout, remote_name: Option<&str>) -> CmdResult<(String, PathBuf)> {
96 if let Some(name) = remote_name {
97 let state = map::state_dir(layout, name).map_err(|e| (e.to_string(), exit::USAGE))?;
98 if !state.is_dir() {
99 return Err((format!("no bridge state for '{name}'"), exit::NOINPUT));
100 }
101 return Ok((name.to_owned(), state));
102 }
103 let names = state_names(layout);
104 match names.as_slice() {
105 [] => Err((
106 "no git bridge state (run `mkit git import` or `mkit git export` first)".into(),
107 exit::NOINPUT,
108 )),
109 [one] => Ok((one.clone(), layout.git_state_dir().join(one))),
110 many => Err((
111 format!(
112 "multiple bridge states ({}); pick one with --remote-name",
113 many.join(", ")
114 ),
115 exit::USAGE,
116 )),
117 }
118}
119
120fn open_repo(cwd: &Path) -> CmdResult<(RepoLayout, ObjectStore)> {
121 let layout = mkit_core::layout::discover(cwd)
122 .map_err(|e| (format!("worktree discovery: {e}"), exit::DATAERR))?;
123 if !layout.common_dir().is_dir() {
124 return Err(("not a mkit repository".into(), exit::USAGE));
125 }
126 let store =
127 ObjectStore::open(&layout).map_err(|e| (format!("open store: {e}"), exit::NOINPUT))?;
128 Ok((layout, store))
129}
130
131#[derive(Default)]
134struct Counts {
135 bridge: usize,
136 unsigned: usize,
137 imported: usize,
138 derived: usize,
139}
140
141struct Audit<'a> {
142 raw_dir: PathBuf,
143 batch: CatFileBatch,
144 store: &'a ObjectStore,
145 inv: HashMap<Sha1Id, Hash>,
147 fwd: HashMap<Hash, Sha1Id>,
149 pinned: Option<[u8; 32]>,
150 deep: bool,
151 failures: Vec<String>,
152 counts: Counts,
153 seen: HashSet<Sha1Id>,
154}
155
156impl Audit<'_> {
157 fn fail(&mut self, id: &Sha1Id, why: &str) {
158 self.failures.push(format!("{} {why}", sha1_hex(id)));
159 }
160
161 fn twin(&mut self, id: &Sha1Id) -> Option<Hash> {
162 let t = self.inv.get(id).copied();
163 if t.is_none() {
164 self.fail(id, "no mkit twin recorded in the map");
165 }
166 t
167 }
168
169 fn raw_path(&self, id: &Sha1Id) -> PathBuf {
170 let hex = sha1_hex(id);
171 self.raw_dir.join(&hex[..2]).join(&hex[2..])
172 }
173
174 fn walk(&mut self, tip: &Sha1Id) {
178 let mut stack = vec![*tip];
179 while let Some(id) = stack.pop() {
180 if !self.seen.insert(id) {
181 continue;
182 }
183 let (kind, body) = match self.batch.read(&id) {
184 Ok(v) => v,
185 Err(e) => {
186 self.fail(&id, &format!("unreadable in staging: {e}"));
187 continue;
188 }
189 };
190 match kind {
191 GitObjKind::Commit => {
192 let parsed = match gitparse::parse_commit(&body) {
193 Ok(p) => p,
194 Err(e) => {
195 self.fail(&id, &format!("unparsable commit: {e}"));
196 continue;
197 }
198 };
199 if self.raw_path(&id).exists() {
200 self.check_imported(&id, false);
201 } else {
202 self.check_bridge_commit(&id, &body);
203 if self.deep {
204 self.audit_object(&parsed.tree);
205 }
206 }
207 for p in &parsed.parents {
208 stack.push(*p);
209 }
210 }
211 GitObjKind::Tag => {
212 if self.raw_path(&id).exists() {
213 self.check_imported(&id, true);
214 } else {
215 self.check_bridge_tag(&id, &body);
216 }
217 if let Ok(t) = gitparse::parse_tag(&body) {
218 stack.push(t.object);
219 }
220 }
221 GitObjKind::Blob | GitObjKind::Tree => {
222 }
225 }
226 }
227 }
228
229 fn check_imported(&mut self, id: &Sha1Id, is_tag: bool) {
233 match std::fs::read(self.raw_path(id)) {
234 Ok(raw) => match GitObject::parse_raw(&raw) {
235 Some(obj) if obj.id() == *id => {}
236 Some(_) => self.fail(id, "retained raw bytes hash to a DIFFERENT sha1"),
237 None => self.fail(id, "retained raw bytes are not framed git bytes"),
238 },
239 Err(e) => self.fail(id, &format!("retained raw bytes unreadable: {e}")),
240 }
241 let Some(twin) = self.twin(id) else { return };
242 match self.store.read_object(&twin) {
243 Ok(Object::Commit(c)) if !is_tag => self
244 .check_importer_sig(id, &c.signer, || mkit_core::sign::verify_commit(&c).is_ok()),
245 Ok(Object::Tag(t)) if is_tag => {
246 let signer = t.signer;
247 self.check_importer_sig(id, &signer, || mkit_core::sign::verify_tag(&t).is_ok());
248 }
249 Ok(_) => self.fail(id, "mkit twin has a different object kind"),
250 Err(e) => self.fail(id, &format!("mkit twin missing from store: {e}")),
251 }
252 self.counts.imported += 1;
253 }
254
255 fn check_importer_sig(&mut self, id: &Sha1Id, signer: &[u8; 32], ok: impl FnOnce() -> bool) {
256 match self.pinned {
257 Some(pin) if pin != *signer => {
258 self.fail(id, "twin signer is NOT the pinned importer key");
259 }
260 None => self.fail(id, "imported object but no importer key pinned"),
261 Some(_) => {
262 if !ok() {
263 self.fail(id, "importer signature does not verify");
264 }
265 }
266 }
267 }
268
269 fn check_bridge_commit(&mut self, id: &Sha1Id, body: &[u8]) {
271 let obj = GitObject {
272 gtype: GitType::Commit,
273 body: body.to_vec(),
274 };
275 self.check_bridge_obj(id, &obj, reconstruct::reconstruct_commit);
276 }
277
278 fn check_bridge_tag(&mut self, id: &Sha1Id, body: &[u8]) {
279 let obj = GitObject {
280 gtype: GitType::Tag,
281 body: body.to_vec(),
282 };
283 self.check_bridge_obj(id, &obj, reconstruct::reconstruct_tag);
284 }
285
286 fn check_bridge_obj(
287 &mut self,
288 id: &Sha1Id,
289 obj: &GitObject,
290 rec: impl Fn(&[u8]) -> Result<reconstruct::Reconstructed, mkit_git_bridge::BridgeError>,
291 ) {
292 match verify::shallow_verify(obj) {
293 Ok(ShallowVerdict::Verified) => {}
294 Ok(ShallowVerdict::Unsigned) => self.counts.unsigned += 1,
295 Ok(ShallowVerdict::Failed) => self.fail(id, "embedded signature does NOT verify"),
296 Err(e) => {
297 self.fail(id, &format!("not bridge-shaped: {e}"));
298 return;
299 }
300 }
301 match rec(&obj.body) {
302 Ok(r) => {
303 if let Some(twin) = self.twin(id)
304 && r.hash != twin
305 {
306 self.fail(id, "reconstructs to a hash OTHER than its mapped twin");
307 }
308 if let Err(e) = self.store.read_object(&r.hash) {
309 self.fail(id, &format!("reconstructed twin missing from store: {e}"));
310 }
311 }
312 Err(e) => self.fail(id, &format!("reconstruction failed: {e}")),
313 }
314 self.counts.bridge += 1;
315 }
316
317 fn audit_object(&mut self, id: &Sha1Id) {
320 if !self.seen.insert(*id) {
321 return;
322 }
323 let Some(twin) = self.twin(id) else { return };
324 let obj = match self.store.read_object(&twin) {
325 Ok(o) => o,
326 Err(e) => {
327 self.fail(id, &format!("mkit twin missing from store: {e}"));
328 return;
329 }
330 };
331 let derived = match &obj {
332 Object::Blob(b) => Ok(translate::translate_blob(&b.data)),
333 Object::ChunkedBlob(m) => translate::translate_chunked(&twin, m, self.store),
334 Object::Tree(t) => {
335 let fwd = &self.fwd;
336 translate::translate_tree(t, &|h| fwd.get(h).copied())
337 }
338 _ => {
339 self.fail(id, "content position holds a non-content mkit twin");
340 return;
341 }
342 };
343 match derived {
344 Ok(g) if g.id() == *id => self.counts.derived += 1,
345 Ok(_) => self.fail(id, "re-derivation produced a DIFFERENT sha1"),
346 Err(e) => {
347 self.fail(id, &format!("re-derivation failed: {e}"));
348 return;
349 }
350 }
351 if let Object::Tree(t) = &obj {
352 for e in &t.entries {
353 if let Some(child) = self.fwd.get(&e.object_hash).copied() {
354 self.audit_object(&child);
355 } else {
356 self.fail(
357 id,
358 &format!(
359 "tree entry {:?} has no recorded sha1",
360 String::from_utf8_lossy(&e.name)
361 ),
362 );
363 }
364 }
365 }
366 }
367}
368
369pub(super) fn verify(args: &VerifyArgs) -> CmdResult<()> {
370 let cwd = std::env::current_dir().map_err(|e| (format!("cwd: {e}"), exit::NOINPUT))?;
371 let (layout, store) = open_repo(&cwd)?;
372 let (name, state) = resolve_state(&layout, args.remote_name.as_deref())?;
373 let staging = state.join("repo.git");
374 if !staging.join("objects").is_dir() {
375 return Err((
376 format!("state '{name}' has no staging repo to verify against"),
377 exit::NOINPUT,
378 ));
379 }
380
381 let mut targets: Vec<(String, Sha1Id, &'static str)> = Vec::new();
384 let recorded_export =
385 map::load_ref_state(&state).map_err(|e| (e.to_string(), exit::GENERAL_ERROR))?;
386 let recorded_import =
387 map::load_import_ref_state(&state).map_err(|e| (e.to_string(), exit::GENERAL_ERROR))?;
388 for s in &recorded_export {
389 if s.ref_name != ATTESTATIONS_REF {
390 targets.push((s.ref_name.clone(), s.git_id, "exported"));
391 }
392 }
393 for s in &recorded_import {
394 if !targets
395 .iter()
396 .any(|(n, id, _)| *n == s.ref_name && *id == s.git_id)
397 {
398 targets.push((s.ref_name.clone(), s.git_id, "imported"));
399 }
400 }
401 if !args.refs.is_empty() {
402 targets.retain(|(n, _, _)| args.refs.iter().any(|r| r == n));
403 for r in &args.refs {
404 if !targets.iter().any(|(n, _, _)| n == r) {
405 return Err((format!("{r}: not recorded in state '{name}'"), exit::USAGE));
406 }
407 }
408 }
409 if targets.is_empty() {
410 return Err((format!("state '{name}' records no refs"), exit::NOINPUT));
411 }
412
413 let mut audit = Audit {
414 raw_dir: state.join("raw"),
415 batch: CatFileBatch::open(&staging).map_err(|e| (e.to_string(), exit::UNAVAILABLE))?,
416 store: &store,
417 inv: map::load_map_inverse(&state).map_err(|e| (e.to_string(), exit::GENERAL_ERROR))?,
418 fwd: map::load_map(&state).map_err(|e| (e.to_string(), exit::GENERAL_ERROR))?,
419 pinned: map::read_signer(&state).map_err(|e| (e.to_string(), exit::CONFIG_ERROR))?,
420 deep: args.fork_audit,
421 failures: Vec::new(),
422 counts: Counts::default(),
423 seen: HashSet::new(),
424 };
425
426 let mut stderr = std::io::stderr().lock();
427 for (ref_name, tip, origin) in &targets {
428 audit.walk(tip);
429 if args.fork_audit
434 && audit.raw_path(tip).exists()
435 && let Some(twin) = audit.inv.get(tip).copied()
436 {
437 let attested = mkit_attest::store::list(&layout, &twin).is_ok_and(|v| !v.is_empty());
438 if !attested {
439 audit.failures.push(format!(
440 "{} imported head has no git-import/v1 attestation recorded",
441 sha1_hex(tip)
442 ));
443 }
444 }
445 let _ = writeln!(stderr, "verified {ref_name} ({origin}, {})", sha1_hex(tip));
446 }
447 let c = &audit.counts;
448 let mut summary = format!(
449 "{} bridge-translated ({} unsigned), {} imported-vouched",
450 c.bridge, c.unsigned, c.imported
451 );
452 if args.fork_audit {
453 let _ = write!(summary, ", {} content-derived", c.derived);
454 }
455 if audit.failures.is_empty() && c.unsigned > 0 {
456 return Err((
460 format!("{} unsigned object(s) ({summary})", c.unsigned),
461 exit::DATAERR,
462 ));
463 }
464 if audit.failures.is_empty() {
465 let _ = writeln!(stderr, "ok: {summary}");
466 Ok(())
467 } else {
468 for f in &audit.failures {
469 let _ = writeln!(stderr, "FAIL {f}");
470 }
471 Err((
472 format!(
473 "{} object(s) failed verification ({summary})",
474 audit.failures.len()
475 ),
476 exit::DATAERR,
477 ))
478 }
479}
480
481pub(super) fn status(_args: &StatusArgs) -> CmdResult<()> {
484 let cwd = std::env::current_dir().map_err(|e| (format!("cwd: {e}"), exit::NOINPUT))?;
485 let (layout, _store) = open_repo(&cwd)?;
486 let names = state_names(&layout);
487 let mut out = std::io::stdout().lock();
488 if names.is_empty() {
489 let _ = writeln!(
490 out,
491 "no git bridge state (run `mkit git import` or `mkit git export` first)"
492 );
493 return Ok(());
494 }
495 for name in &names {
496 let state = layout.git_state_dir().join(name);
497 let direction = map::read_direction(&state)
498 .ok()
499 .flatten()
500 .map_or("unknown", |d| d.as_str());
501 let _ = writeln!(out, "{name} direction={direction}");
502 for (file, label) in [("source", "source"), ("dest", "dest")] {
503 if let Ok(v) = std::fs::read_to_string(state.join(file)) {
504 let _ = writeln!(out, " {label}: {}", v.trim());
505 }
506 }
507 if let Ok(Some(key)) = map::read_signer(&state) {
508 let _ = writeln!(
509 out,
510 " importer key: {}… (pinned)",
511 &mkit_git_bridge::gitobj::bytes_hex(&key)[..16]
512 );
513 }
514 for s in map::load_import_ref_state(&state).unwrap_or_default() {
515 let _ = writeln!(
516 out,
517 " tracking {} @ {} (import)",
518 s.ref_name,
519 &sha1_hex(&s.git_id)[..12]
520 );
521 }
522 for s in map::load_ref_state(&state).unwrap_or_default() {
523 if s.ref_name == ATTESTATIONS_REF {
524 continue;
525 }
526 let _ = writeln!(
527 out,
528 " exported {} @ {} (lease)",
529 s.ref_name,
530 &sha1_hex(&s.git_id)[..12]
531 );
532 }
533 let staging = if state.join("repo.git/objects").is_dir() {
534 "ok"
535 } else {
536 "missing"
537 };
538 let _ = writeln!(out, " staging: {staging}");
539 }
540 Ok(())
541}
542
543type Series = (Vec<Hash>, HashMap<Hash, Commit>, String, String);
548
549fn range_commits(store: &ObjectStore, layout: &RepoLayout, range: &str) -> CmdResult<Series> {
553 let (a, b) = match range.split_once("..") {
554 Some((a, b)) => (
555 a.to_owned(),
556 if b.is_empty() {
557 "HEAD".to_owned()
558 } else {
559 b.to_owned()
560 },
561 ),
562 None => (range.to_owned(), "HEAD".to_owned()),
563 };
564 let resolve = |spec: &str| -> CmdResult<Hash> {
565 revspec::resolve_revision(store, layout, spec)
566 .map_err(|e| (format!("{spec}: {e}"), exit::DATAERR))
567 };
568 let exclude_tip = peel(store, resolve(&a)?);
569 let include_tip = peel(store, resolve(&b)?);
570 for (spec, h) in [(&a, exclude_tip), (&b, include_tip)] {
571 if !matches!(store.read_object(&h), Ok(Object::Commit(_))) {
572 return Err((format!("{spec}: not a commit"), exit::DATAERR));
575 }
576 }
577
578 let mut excluded: HashSet<Hash> = HashSet::new();
580 let mut stack = vec![exclude_tip];
581 while let Some(h) = stack.pop() {
582 if !excluded.insert(h) {
583 continue;
584 }
585 if let Ok(Object::Commit(c)) = store.read_object(&h) {
586 stack.extend(c.parents.iter().copied());
587 }
588 }
589 let mut commits: HashMap<Hash, Commit> = HashMap::new();
590 let mut stack = vec![include_tip];
591 while let Some(h) = stack.pop() {
592 if excluded.contains(&h) || commits.contains_key(&h) {
593 continue;
594 }
595 let c = match store.read_object(&h) {
596 Ok(Object::Commit(c)) => c,
597 Ok(_) => {
598 return Err((
599 format!("not a commit: {}", mkit_core::to_hex(&h)),
600 exit::DATAERR,
601 ));
602 }
603 Err(e) => {
604 return Err((
605 format!("read {}: {e}", mkit_core::to_hex(&h)),
606 exit::DATAERR,
607 ));
608 }
609 };
610 stack.extend(c.parents.iter().copied());
611 commits.insert(h, c);
612 }
613
614 let mut remaining: Vec<Hash> = commits.keys().copied().collect();
615 remaining.sort_by_key(|h| (commits[h].timestamp, *h));
616 let mut ordered: Vec<Hash> = Vec::with_capacity(remaining.len());
617 let mut placed: HashSet<Hash> = HashSet::new();
618 while !remaining.is_empty() {
619 let before = ordered.len();
620 remaining.retain(|h| {
621 let ready = commits[h]
622 .parents
623 .iter()
624 .all(|p| placed.contains(p) || !commits.contains_key(p));
625 if ready {
626 ordered.push(*h);
627 placed.insert(*h);
628 }
629 !ready
630 });
631 if ordered.len() == before {
632 return Err(("commit graph cycle (corrupt store?)".into(), exit::DATAERR));
633 }
634 }
635 Ok((ordered, commits, a, b))
636}
637
638pub(super) fn format_patch(args: &FormatPatchArgs) -> CmdResult<()> {
639 let cwd = std::env::current_dir().map_err(|e| (format!("cwd: {e}"), exit::NOINPUT))?;
640 let (layout, store) = open_repo(&cwd)?;
641 let (ordered, commits, a, b) = range_commits(&store, &layout, &args.range)?;
642
643 let mut skipped_merges = 0usize;
644 let series: Vec<&Hash> = ordered
645 .iter()
646 .filter(|h| {
647 let m = commits[*h].parents.len() > 1;
648 if m {
649 skipped_merges += 1;
650 }
651 !m
652 })
653 .collect();
654 if skipped_merges > 0 {
655 eprintln!("warning: {skipped_merges} merge commit(s) skipped (patches are linear)");
656 }
657 if series.is_empty() {
658 eprintln!("no commits in range {a}..{b}");
659 return Ok(());
660 }
661
662 let total = series.len();
663 let outdir = args.output.clone().unwrap_or_else(|| cwd.clone());
664 if !args.stdout {
665 std::fs::create_dir_all(&outdir)
666 .map_err(|e| (format!("create {}: {e}", outdir.display()), exit::CANTCREAT))?;
667 }
668 let mut stdout = std::io::stdout().lock();
669 for (i, h) in series.iter().enumerate() {
670 let c = &commits[*h];
671 let text = render_patch(&store, h, c, i + 1, total)?;
672 if args.stdout {
673 stdout
674 .write_all(text.as_bytes())
675 .map_err(|e| (format!("write: {e}"), exit::GENERAL_ERROR))?;
676 } else {
677 let name = format!("{:04}-{}.patch", i + 1, slug(&subject_of(c)));
678 let path = outdir.join(&name);
679 std::fs::write(&path, &text)
680 .map_err(|e| (format!("write {}: {e}", path.display()), exit::CANTCREAT))?;
681 let _ = writeln!(stdout, "{name}");
682 }
683 }
684 Ok(())
685}
686
687fn peel(store: &ObjectStore, mut h: Hash) -> Hash {
688 while let Ok(Object::Tag(t)) = store.read_object(&h) {
689 h = t.target;
690 }
691 h
692}
693
694fn subject_of(c: &Commit) -> String {
695 let msg = String::from_utf8_lossy(&c.message);
696 msg.lines().next().unwrap_or("").to_owned()
697}
698
699fn slug(subject: &str) -> String {
700 let mut out = String::new();
701 for ch in subject.chars() {
702 if ch.is_ascii_alphanumeric() {
703 out.push(ch);
704 } else if !out.ends_with('-') && !out.is_empty() {
705 out.push('-');
706 }
707 }
708 let trimmed = out.trim_end_matches('-');
709 let cut = trimmed.chars().take(52).collect::<String>();
710 if cut.is_empty() { "patch".into() } else { cut }
711}
712
713fn render_patch(
714 store: &ObjectStore,
715 hash: &Hash,
716 c: &Commit,
717 n: usize,
718 total: usize,
719) -> CmdResult<String> {
720 let mut out = String::new();
721 let _ = writeln!(
724 out,
725 "From {} Mon Sep 17 00:00:00 2001",
726 mkit_core::to_hex(hash)
727 );
728 let _ = writeln!(out, "From: {}", from_header(c));
729 let _ = writeln!(out, "Date: {}", rfc2822(c.timestamp));
730 let msg = String::from_utf8_lossy(&c.message);
731 let mut lines = msg.lines();
732 let subject = lines.next().unwrap_or("");
733 if total > 1 {
734 let _ = write!(out, "Subject: [PATCH {n}/{total}] {subject}\n\n");
735 } else {
736 let _ = write!(out, "Subject: [PATCH] {subject}\n\n");
737 }
738 let body: Vec<&str> = lines.skip_while(|l| l.is_empty()).collect();
739 for l in &body {
740 if is_mbox_from_line(l) {
747 out.push('>');
748 }
749 out.push_str(l);
750 out.push('\n');
751 }
752 out.push_str("---\n");
753
754 let old_tree = match c.parents.first() {
755 Some(p) => match store.read_object(p) {
756 Ok(Object::Commit(pc)) => Some(pc.tree_hash),
757 _ => None,
758 },
759 None => None,
760 };
761 let diff = mkit_core::ops::diff::diff_trees(store, old_tree, Some(c.tree_hash))
762 .map_err(|e| (format!("diff: {e}"), exit::GENERAL_ERROR))?;
763 let mut buf: Vec<u8> = Vec::new();
764 for e in &diff.entries {
765 let mut one: Vec<u8> = Vec::new();
766 super::diff::emit_entry_patch(
772 &mut one,
773 store,
774 e,
775 mkit_core::ops::DEFAULT_CONTEXT_LINES,
776 mkit_core::ops::WhitespaceMode::Exact,
777 )
778 .map_err(|m| (m, exit::GENERAL_ERROR))?;
779 if one
784 .split(|&b| b == b'\n')
785 .any(|l| l.starts_with(b"Binary files "))
786 {
787 return Err((
788 format!(
789 "{}: binary change in commit {} — format-patch emits text \
790 patches only; use `mkit git export` for binary content",
791 e.path,
792 &mkit_core::to_hex(hash)[..12]
793 ),
794 exit::DATAERR,
795 ));
796 }
797 buf.extend_from_slice(&one);
798 }
799 out.push_str(&String::from_utf8_lossy(&buf));
800 out.push_str("-- \nmkit git format-patch\n\n");
801 Ok(out)
802}
803
804fn from_header(c: &Commit) -> String {
809 if let Ok(s) = std::str::from_utf8(&c.author.bytes)
810 && c.author.kind == mkit_core::object::IdentityKind::Opaque
811 && s.contains('<')
812 && s.ends_with('>')
813 && !s.chars().any(char::is_control)
814 {
815 return s.to_owned();
816 }
817 format!(
818 "{} <{}>",
819 author::display_name(&c.author),
820 author::BRIDGE_EMAIL
821 )
822}
823
824fn is_mbox_from_line(line: &str) -> bool {
832 const WDAYS: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
833 const MONS: [&str; 12] = [
834 "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
835 ];
836 let Some(rest) = line.strip_prefix("From ") else {
837 return false;
838 };
839 let tokens: Vec<&str> = rest.split_whitespace().collect();
840 tokens.len() >= 6
842 && tokens.windows(5).skip(1).any(|w| {
843 let [wday, mon, day, time, year] = w else {
844 return false;
845 };
846 WDAYS.contains(wday)
847 && MONS.contains(mon)
848 && (1..=2).contains(&day.len())
849 && day.bytes().all(|b| b.is_ascii_digit())
850 && time.len() == 8
851 && time.as_bytes()[2] == b':'
852 && time.as_bytes()[5] == b':'
853 && year.len() == 4
854 && year.bytes().all(|b| b.is_ascii_digit())
855 })
856}
857
858fn rfc2822(ts: u64) -> String {
860 const WDAY: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
861 const MON: [&str; 12] = [
862 "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
863 ];
864 #[allow(clippy::cast_possible_wrap)] let days = (ts / 86_400) as i64;
866 let secs = ts % 86_400;
867 let (y, m, d) = civil_from_days(days);
868 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] let wd = ((days + 4).rem_euclid(7)) as usize; format!(
871 "{}, {} {} {} {:02}:{:02}:{:02} +0000",
872 WDAY[wd],
873 d,
874 MON[(m - 1) as usize],
875 y,
876 secs / 3600,
877 secs % 3600 / 60,
878 secs % 60
879 )
880}
881
882#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] fn civil_from_days(z: i64) -> (i64, u32, u32) {
886 let z = z + 719_468;
887 let era = z.div_euclid(146_097);
888 let doe = z.rem_euclid(146_097);
889 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
890 let y = yoe + era * 400;
891 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
892 let mp = (5 * doy + 2) / 153;
893 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
894 let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
895 (if m <= 2 { y + 1 } else { y }, m, d)
896}
897
898#[cfg(test)]
899mod tests {
900 use super::*;
901
902 #[test]
903 fn rfc2822_known_dates() {
904 assert_eq!(rfc2822(0), "Thu, 1 Jan 1970 00:00:00 +0000");
905 assert_eq!(rfc2822(1_700_000_000), "Tue, 14 Nov 2023 22:13:20 +0000");
907 assert_eq!(rfc2822(1_709_208_000), "Thu, 29 Feb 2024 12:00:00 +0000");
909 }
910
911 #[test]
912 fn mbox_from_line_shapes() {
913 assert!(is_mbox_from_line(
914 "From 1234567890abcdef1234567890abcdef12345678 Mon Sep 17 00:00:00 2001"
915 ));
916 assert!(is_mbox_from_line("From x Thu Jan 1 00:00:00 1970"));
917 assert!(is_mbox_from_line(
921 "From sender Fri Jun 12 12:00:00 2026 +0000"
922 ));
923 assert!(!is_mbox_from_line("From the start, this was true."));
925 assert!(!is_mbox_from_line("From: someone <a@b>"));
926 assert!(!is_mbox_from_line("From abc Mon Sep 17 00:00 2001")); }
928
929 #[test]
930 fn slugs() {
931 assert_eq!(slug("Add foo, bar & baz!"), "Add-foo-bar-baz");
932 assert_eq!(slug("???"), "patch");
933 }
934
935 #[test]
949 fn state_names_and_resolve_state_skip_dot_leading_orphans() {
950 let dir = tempfile::tempdir().unwrap();
951 let layout = RepoLayout::single(dir.path());
952 let state_dir = layout.git_state_dir();
953 std::fs::create_dir_all(state_dir.join("orig")).unwrap();
954 std::fs::write(state_dir.join("orig").join("marker.txt"), b"real state\n").unwrap();
955 let orphan = state_dir.join(".rename.tmp.99999.0");
958 std::fs::create_dir_all(&orphan).unwrap();
959 std::fs::write(orphan.join("marker.txt"), b"orphaned bridge state\n").unwrap();
960
961 assert_eq!(
962 state_names(&layout),
963 vec!["orig".to_string()],
964 "state_names must skip the dot-leading orphan"
965 );
966 let (name, path) = resolve_state(&layout, None).expect(
967 "zero-arg resolution must pick the lone legitimate state \
968 instead of erroring 'multiple bridge states' over the orphan",
969 );
970 assert_eq!(name, "orig");
971 assert_eq!(path, state_dir.join("orig"));
972 }
973}