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::{author, gitparse, map};
30
31use super::revspec;
32use crate::exit;
33
34type CmdResult<T> = Result<T, (String, u8)>;
35
36const ATTESTATIONS_REF: &str = "refs/mkit/attestations";
37
38#[derive(Debug, Parser)]
39pub(super) struct VerifyArgs {
40 #[arg(long = "remote-name", value_name = "NAME")]
43 pub remote_name: Option<String>,
44 #[arg(long = "fork-audit")]
48 pub fork_audit: bool,
49 #[arg(long = "ref", value_name = "REF")]
52 pub refs: Vec<String>,
53}
54
55#[derive(Debug, Parser)]
56pub(super) struct StatusArgs {}
57
58#[derive(Debug, Parser)]
59pub(super) struct FormatPatchArgs {
60 pub range: String,
62 #[arg(short = 'o', long = "output-directory", value_name = "DIR")]
64 pub output: Option<PathBuf>,
65 #[arg(long)]
67 pub stdout: bool,
68}
69
70fn state_names(layout: &RepoLayout) -> Vec<String> {
73 let mut names = Vec::new();
74 if let Ok(rd) = std::fs::read_dir(layout.git_state_dir()) {
75 for e in rd.flatten() {
76 if e.path().is_dir()
77 && let Some(name) = e.file_name().to_str()
78 && !name.starts_with('.')
85 {
86 names.push(name.to_owned());
87 }
88 }
89 }
90 names.sort();
91 names
92}
93
94fn resolve_state(layout: &RepoLayout, remote_name: Option<&str>) -> CmdResult<(String, PathBuf)> {
95 if let Some(name) = remote_name {
96 let state = map::state_dir(layout, name).map_err(|e| (e.to_string(), exit::USAGE))?;
97 if !state.is_dir() {
98 return Err((format!("no bridge state for '{name}'"), exit::NOINPUT));
99 }
100 return Ok((name.to_owned(), state));
101 }
102 let names = state_names(layout);
103 match names.as_slice() {
104 [] => Err((
105 "no git bridge state (run `mkit git import` or `mkit git export` first)".into(),
106 exit::NOINPUT,
107 )),
108 [one] => Ok((one.clone(), layout.git_state_dir().join(one))),
109 many => Err((
110 format!(
111 "multiple bridge states ({}); pick one with --remote-name",
112 many.join(", ")
113 ),
114 exit::USAGE,
115 )),
116 }
117}
118
119fn open_repo(cwd: &Path) -> CmdResult<(RepoLayout, ObjectStore)> {
120 let layout = mkit_core::layout::discover(cwd)
121 .map_err(|e| (format!("worktree discovery: {e}"), exit::DATAERR))?;
122 if !layout.common_dir().is_dir() {
123 return Err(("not a mkit repository".into(), exit::USAGE));
124 }
125 let store =
126 ObjectStore::open(&layout).map_err(|e| (format!("open store: {e}"), exit::NOINPUT))?;
127 Ok((layout, store))
128}
129
130mod audit;
133
134pub(super) fn verify(args: &VerifyArgs) -> CmdResult<()> {
135 audit::run(args)
136}
137
138pub(super) fn status(_args: &StatusArgs) -> CmdResult<()> {
141 let cwd = std::env::current_dir().map_err(|e| (format!("cwd: {e}"), exit::NOINPUT))?;
142 let (layout, _store) = open_repo(&cwd)?;
143 let names = state_names(&layout);
144 let mut out = std::io::stdout().lock();
145 if names.is_empty() {
146 let _ = writeln!(
147 out,
148 "no git bridge state (run `mkit git import` or `mkit git export` first)"
149 );
150 return Ok(());
151 }
152 for name in &names {
153 let state = layout.git_state_dir().join(name);
154 let direction = map::read_direction(&state)
155 .ok()
156 .flatten()
157 .map_or("unknown", |d| d.as_str());
158 let _ = writeln!(out, "{name} direction={direction}");
159 for (file, label) in [("source", "source"), ("dest", "dest")] {
160 if let Ok(v) = std::fs::read_to_string(state.join(file)) {
161 let _ = writeln!(out, " {label}: {}", v.trim());
162 }
163 }
164 if let Ok(Some(key)) = map::read_signer(&state) {
165 let _ = writeln!(
166 out,
167 " importer key: {}… (pinned)",
168 &mkit_git_bridge::gitobj::bytes_hex(&key)[..16]
169 );
170 }
171 for s in map::load_import_ref_state(&state).unwrap_or_default() {
172 let _ = writeln!(
173 out,
174 " tracking {} @ {} (import)",
175 s.ref_name,
176 &sha1_hex(&s.git_id)[..12]
177 );
178 }
179 for s in map::load_ref_state(&state).unwrap_or_default() {
180 if s.ref_name == ATTESTATIONS_REF {
181 continue;
182 }
183 let _ = writeln!(
184 out,
185 " exported {} @ {} (lease)",
186 s.ref_name,
187 &sha1_hex(&s.git_id)[..12]
188 );
189 }
190 let staging = if state.join("repo.git/objects").is_dir() {
191 "ok"
192 } else {
193 "missing"
194 };
195 let _ = writeln!(out, " staging: {staging}");
196 }
197 Ok(())
198}
199
200type Series = (Vec<Hash>, HashMap<Hash, Commit>, String, String);
205
206fn range_commits(store: &ObjectStore, layout: &RepoLayout, range: &str) -> CmdResult<Series> {
210 let (a, b) = match range.split_once("..") {
211 Some((a, b)) => (
212 a.to_owned(),
213 if b.is_empty() {
214 "HEAD".to_owned()
215 } else {
216 b.to_owned()
217 },
218 ),
219 None => (range.to_owned(), "HEAD".to_owned()),
220 };
221 let resolve = |spec: &str| -> CmdResult<Hash> {
222 revspec::resolve_revision(store, layout, spec)
223 .map_err(|e| (format!("{spec}: {e}"), exit::DATAERR))
224 };
225 let exclude_tip = peel(store, resolve(&a)?);
226 let include_tip = peel(store, resolve(&b)?);
227 for (spec, h) in [(&a, exclude_tip), (&b, include_tip)] {
228 if !matches!(store.read_object(&h), Ok(Object::Commit(_))) {
229 return Err((format!("{spec}: not a commit"), exit::DATAERR));
232 }
233 }
234
235 let mut excluded: HashSet<Hash> = HashSet::new();
237 let mut stack = vec![exclude_tip];
238 while let Some(h) = stack.pop() {
239 if !excluded.insert(h) {
240 continue;
241 }
242 if let Ok(Object::Commit(c)) = store.read_object(&h) {
243 stack.extend(c.parents.iter().copied());
244 }
245 }
246 let mut commits: HashMap<Hash, Commit> = HashMap::new();
247 let mut stack = vec![include_tip];
248 while let Some(h) = stack.pop() {
249 if excluded.contains(&h) || commits.contains_key(&h) {
250 continue;
251 }
252 let c = match store.read_object(&h) {
253 Ok(Object::Commit(c)) => c,
254 Ok(_) => {
255 return Err((
256 format!("not a commit: {}", mkit_core::to_hex(&h)),
257 exit::DATAERR,
258 ));
259 }
260 Err(e) => {
261 return Err((
262 format!("read {}: {e}", mkit_core::to_hex(&h)),
263 exit::DATAERR,
264 ));
265 }
266 };
267 stack.extend(c.parents.iter().copied());
268 commits.insert(h, c);
269 }
270
271 let mut remaining: Vec<Hash> = commits.keys().copied().collect();
272 remaining.sort_by_key(|h| (commits[h].timestamp, *h));
273 let mut ordered: Vec<Hash> = Vec::with_capacity(remaining.len());
274 let mut placed: HashSet<Hash> = HashSet::new();
275 while !remaining.is_empty() {
276 let before = ordered.len();
277 remaining.retain(|h| {
278 let ready = commits[h]
279 .parents
280 .iter()
281 .all(|p| placed.contains(p) || !commits.contains_key(p));
282 if ready {
283 ordered.push(*h);
284 placed.insert(*h);
285 }
286 !ready
287 });
288 if ordered.len() == before {
289 return Err(("commit graph cycle (corrupt store?)".into(), exit::DATAERR));
290 }
291 }
292 Ok((ordered, commits, a, b))
293}
294
295pub(super) fn format_patch(args: &FormatPatchArgs) -> CmdResult<()> {
296 let cwd = std::env::current_dir().map_err(|e| (format!("cwd: {e}"), exit::NOINPUT))?;
297 let (layout, store) = open_repo(&cwd)?;
298 let (ordered, commits, a, b) = range_commits(&store, &layout, &args.range)?;
299
300 let mut skipped_merges = 0usize;
301 let series: Vec<&Hash> = ordered
302 .iter()
303 .filter(|h| {
304 let m = commits[*h].parents.len() > 1;
305 if m {
306 skipped_merges += 1;
307 }
308 !m
309 })
310 .collect();
311 if skipped_merges > 0 {
312 eprintln!("warning: {skipped_merges} merge commit(s) skipped (patches are linear)");
313 }
314 if series.is_empty() {
315 eprintln!("no commits in range {a}..{b}");
316 return Ok(());
317 }
318
319 let total = series.len();
320 let outdir = args.output.clone().unwrap_or_else(|| cwd.clone());
321 if !args.stdout {
322 std::fs::create_dir_all(&outdir)
323 .map_err(|e| (format!("create {}: {e}", outdir.display()), exit::CANTCREAT))?;
324 }
325 let mut stdout = std::io::stdout().lock();
326 for (i, h) in series.iter().enumerate() {
327 let c = &commits[*h];
328 let text = render_patch(&store, h, c, i + 1, total)?;
329 if args.stdout {
330 stdout
331 .write_all(text.as_bytes())
332 .map_err(|e| (format!("write: {e}"), exit::GENERAL_ERROR))?;
333 } else {
334 let name = format!("{:04}-{}.patch", i + 1, slug(&subject_of(c)));
335 let path = outdir.join(&name);
336 std::fs::write(&path, &text)
337 .map_err(|e| (format!("write {}: {e}", path.display()), exit::CANTCREAT))?;
338 let _ = writeln!(stdout, "{name}");
339 }
340 }
341 Ok(())
342}
343
344fn peel(store: &ObjectStore, mut h: Hash) -> Hash {
345 while let Ok(Object::Tag(t)) = store.read_object(&h) {
346 h = t.target;
347 }
348 h
349}
350
351fn subject_of(c: &Commit) -> String {
352 let msg = String::from_utf8_lossy(&c.message);
353 msg.lines().next().unwrap_or("").to_owned()
354}
355
356fn slug(subject: &str) -> String {
357 let mut out = String::new();
358 for ch in subject.chars() {
359 if ch.is_ascii_alphanumeric() {
360 out.push(ch);
361 } else if !out.ends_with('-') && !out.is_empty() {
362 out.push('-');
363 }
364 }
365 let trimmed = out.trim_end_matches('-');
366 let cut = trimmed.chars().take(52).collect::<String>();
367 if cut.is_empty() { "patch".into() } else { cut }
368}
369
370fn render_patch(
371 store: &ObjectStore,
372 hash: &Hash,
373 c: &Commit,
374 n: usize,
375 total: usize,
376) -> CmdResult<String> {
377 let mut out = String::new();
378 let _ = writeln!(
381 out,
382 "From {} Mon Sep 17 00:00:00 2001",
383 mkit_core::to_hex(hash)
384 );
385 let _ = writeln!(out, "From: {}", from_header(c));
386 let _ = writeln!(out, "Date: {}", rfc2822(c.timestamp));
387 let msg = String::from_utf8_lossy(&c.message);
388 let mut lines = msg.lines();
389 let subject = lines.next().unwrap_or("");
390 if total > 1 {
391 let _ = write!(out, "Subject: [PATCH {n}/{total}] {subject}\n\n");
392 } else {
393 let _ = write!(out, "Subject: [PATCH] {subject}\n\n");
394 }
395 let body: Vec<&str> = lines.skip_while(|l| l.is_empty()).collect();
396 for l in &body {
397 if is_mbox_from_line(l) {
404 out.push('>');
405 }
406 out.push_str(l);
407 out.push('\n');
408 }
409 out.push_str("---\n");
410
411 let old_tree = match c.parents.first() {
412 Some(p) => match store.read_object(p) {
413 Ok(Object::Commit(pc)) => Some(pc.tree_hash),
414 _ => None,
415 },
416 None => None,
417 };
418 let diff = mkit_core::ops::diff::diff_trees(store, old_tree, Some(c.tree_hash))
419 .map_err(|e| (format!("diff: {e}"), exit::GENERAL_ERROR))?;
420 let mut buf: Vec<u8> = Vec::new();
421 for e in &diff.entries {
422 let mut one: Vec<u8> = Vec::new();
423 super::diff::emit_entry_patch(
429 &mut one,
430 store,
431 e,
432 mkit_core::ops::DEFAULT_CONTEXT_LINES,
433 mkit_core::ops::WhitespaceMode::Exact,
434 )
435 .map_err(|m| (m, exit::GENERAL_ERROR))?;
436 if one
441 .split(|&b| b == b'\n')
442 .any(|l| l.starts_with(b"Binary files "))
443 {
444 return Err((
445 format!(
446 "{}: binary change in commit {} — format-patch emits text \
447 patches only; use `mkit git export` for binary content",
448 e.path,
449 &mkit_core::to_hex(hash)[..12]
450 ),
451 exit::DATAERR,
452 ));
453 }
454 buf.extend_from_slice(&one);
455 }
456 out.push_str(&String::from_utf8_lossy(&buf));
457 out.push_str("-- \nmkit git format-patch\n\n");
458 Ok(out)
459}
460
461fn from_header(c: &Commit) -> String {
466 if let Ok(s) = std::str::from_utf8(&c.author.bytes)
467 && c.author.kind == mkit_core::object::IdentityKind::Opaque
468 && s.contains('<')
469 && s.ends_with('>')
470 && !s.chars().any(char::is_control)
471 {
472 return s.to_owned();
473 }
474 format!(
475 "{} <{}>",
476 author::display_name(&c.author),
477 author::BRIDGE_EMAIL
478 )
479}
480
481fn is_mbox_from_line(line: &str) -> bool {
489 const WDAYS: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
490 const MONS: [&str; 12] = [
491 "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
492 ];
493 let Some(rest) = line.strip_prefix("From ") else {
494 return false;
495 };
496 let tokens: Vec<&str> = rest.split_whitespace().collect();
497 tokens.len() >= 6
499 && tokens.windows(5).skip(1).any(|w| {
500 let [wday, mon, day, time, year] = w else {
501 return false;
502 };
503 WDAYS.contains(wday)
504 && MONS.contains(mon)
505 && (1..=2).contains(&day.len())
506 && day.bytes().all(|b| b.is_ascii_digit())
507 && time.len() == 8
508 && time.as_bytes()[2] == b':'
509 && time.as_bytes()[5] == b':'
510 && year.len() == 4
511 && year.bytes().all(|b| b.is_ascii_digit())
512 })
513}
514
515fn rfc2822(ts: u64) -> String {
517 const WDAY: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
518 const MON: [&str; 12] = [
519 "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
520 ];
521 #[allow(clippy::cast_possible_wrap)] let days = (ts / 86_400) as i64;
523 let secs = ts % 86_400;
524 let (y, m, d) = civil_from_days(days);
525 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] let wd = ((days + 4).rem_euclid(7)) as usize; format!(
528 "{}, {} {} {} {:02}:{:02}:{:02} +0000",
529 WDAY[wd],
530 d,
531 MON[(m - 1) as usize],
532 y,
533 secs / 3600,
534 secs % 3600 / 60,
535 secs % 60
536 )
537}
538
539#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] fn civil_from_days(z: i64) -> (i64, u32, u32) {
543 let z = z + 719_468;
544 let era = z.div_euclid(146_097);
545 let doe = z.rem_euclid(146_097);
546 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
547 let y = yoe + era * 400;
548 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
549 let mp = (5 * doy + 2) / 153;
550 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
551 let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
552 (if m <= 2 { y + 1 } else { y }, m, d)
553}
554
555#[cfg(test)]
556mod tests {
557 use super::*;
558
559 #[test]
560 fn rfc2822_known_dates() {
561 assert_eq!(rfc2822(0), "Thu, 1 Jan 1970 00:00:00 +0000");
562 assert_eq!(rfc2822(1_700_000_000), "Tue, 14 Nov 2023 22:13:20 +0000");
564 assert_eq!(rfc2822(1_709_208_000), "Thu, 29 Feb 2024 12:00:00 +0000");
566 }
567
568 #[test]
569 fn mbox_from_line_shapes() {
570 assert!(is_mbox_from_line(
571 "From 1234567890abcdef1234567890abcdef12345678 Mon Sep 17 00:00:00 2001"
572 ));
573 assert!(is_mbox_from_line("From x Thu Jan 1 00:00:00 1970"));
574 assert!(is_mbox_from_line(
578 "From sender Fri Jun 12 12:00:00 2026 +0000"
579 ));
580 assert!(!is_mbox_from_line("From the start, this was true."));
582 assert!(!is_mbox_from_line("From: someone <a@b>"));
583 assert!(!is_mbox_from_line("From abc Mon Sep 17 00:00 2001")); }
585
586 #[test]
587 fn slugs() {
588 assert_eq!(slug("Add foo, bar & baz!"), "Add-foo-bar-baz");
589 assert_eq!(slug("???"), "patch");
590 }
591
592 #[test]
606 fn state_names_and_resolve_state_skip_dot_leading_orphans() {
607 let dir = tempfile::tempdir().unwrap();
608 let layout = RepoLayout::single(dir.path());
609 let state_dir = layout.git_state_dir();
610 std::fs::create_dir_all(state_dir.join("orig")).unwrap();
611 std::fs::write(state_dir.join("orig").join("marker.txt"), b"real state\n").unwrap();
612 let orphan = state_dir.join(".rename.tmp.99999.0");
615 std::fs::create_dir_all(&orphan).unwrap();
616 std::fs::write(orphan.join("marker.txt"), b"orphaned bridge state\n").unwrap();
617
618 assert_eq!(
619 state_names(&layout),
620 vec!["orig".to_string()],
621 "state_names must skip the dot-leading orphan"
622 );
623 let (name, path) = resolve_state(&layout, None).expect(
624 "zero-arg resolution must pick the lone legitimate state \
625 instead of erroring 'multiple bridge states' over the orphan",
626 );
627 assert_eq!(name, "orig");
628 assert_eq!(path, state_dir.join("orig"));
629 }
630}