1use chrono::DateTime;
34use chrono::Utc;
35use git2::Repository;
36use git2::Sort;
37
38use std::collections::HashMap;
39use std::collections::HashSet;
40use std::collections::hash_map::Entry;
41use std::error::Error;
42use std::fs;
43use std::path::Path;
44use std::path::PathBuf;
45
46use log::info;
47use log::warn;
48
49use crate::vim_commit::CommitSaver;
50use crate::vim_commit::canonical_repo_name;
51use crate::vim_commit::ensure_diary_file;
52use crate::vim_commit::is_repo_excluded;
53
54#[derive(Debug, Default, Clone, PartialEq, Eq)]
56pub struct ReconcileReport {
57 pub repository: String,
59
60 pub excluded: bool,
63
64 pub scanned: usize,
66
67 pub appended: usize,
69
70 pub already_present: usize,
73}
74
75#[must_use]
82pub fn hashes_in_note(contents: &str) -> HashSet<String> {
83 contents
84 .lines()
85 .filter_map(last_table_column)
86 .filter(|value| looks_like_object_id(value))
87 .map(str::to_owned)
88 .collect()
89}
90
91fn last_table_column(line: &str) -> Option<&str> {
97 let line = line.trim();
98 let inner = line.strip_prefix('|')?.strip_suffix('|')?;
99
100 inner.rsplit('|').next().map(str::trim)
101}
102
103fn looks_like_object_id(value: &str) -> bool {
121 value.len() >= 7 && value.chars().all(|character| character.is_ascii_hexdigit())
122}
123
124pub fn reconcile_repo(
146 repo: &Repository,
147 obsidian_root_path_dir: &Path,
148 obsidian_commit_path: &Path,
149 template_commit_date_path: &str,
150 template_commit_datetime: &str,
151 excluded_repos: &[String],
152 since: Option<DateTime<Utc>>,
153) -> Result<ReconcileReport, Box<dyn Error>> {
154 let repository = canonical_repo_name(repo).unwrap_or_else(|| "unknown".to_string());
155
156 let mut report = ReconcileReport {
157 repository: repository.clone(),
158 ..ReconcileReport::default()
159 };
160
161 if is_repo_excluded(&repository, excluded_repos) {
162 info!("[reconcile_repo()]: repo '{repository}' is excluded; reading nothing.");
163 report.excluded = true;
164 return Ok(report);
165 }
166
167 let head = repo.head()?;
168 let branch = head.shorthand().unwrap_or("no_branch_set").to_string();
169
170 let folder = repo
173 .workdir()
174 .map_or_else(|| repo.path().to_path_buf(), Path::to_path_buf);
175
176 let mut revwalk = repo.revwalk()?;
177 revwalk.set_sorting(Sort::TIME | Sort::REVERSE)?;
178 revwalk.push_head()?;
179
180 let mut known: HashMap<PathBuf, HashSet<String>> = HashMap::new();
183
184 for oid in revwalk {
185 let commit = repo.find_commit(oid?)?;
186 let mut saver = CommitSaver::from_commit(repo, &commit, &branch)?;
187
188 if let Some(floor) = since {
189 if saver.commit_datetime < floor {
190 continue;
191 }
192 }
193
194 report.scanned += 1;
195
196 let note = saver.diary_path_for(
197 obsidian_root_path_dir,
198 obsidian_commit_path,
199 template_commit_date_path,
200 );
201
202 let recorded = match known.entry(note.clone()) {
203 Entry::Occupied(already_read) => already_read.into_mut(),
204 Entry::Vacant(slot) => {
205 let seeded = if note.exists() {
206 hashes_in_note(&fs::read_to_string(¬e)?)
207 } else {
208 HashSet::new()
209 };
210 slot.insert(seeded)
211 }
212 };
213
214 if recorded.contains(&saver.commit_hash) {
215 report.already_present += 1;
216 continue;
217 }
218
219 ensure_diary_file(¬e, &mut saver)?;
220 saver.append_row_to_diary(¬e, &folder, template_commit_datetime)?;
221
222 recorded.insert(saver.commit_hash.clone());
223 report.appended += 1;
224 }
225
226 info!(
227 "[reconcile_repo()]: '{repository}': {} scanned, {} appended, {} already present.",
228 report.scanned, report.appended, report.already_present
229 );
230
231 Ok(report)
232}
233
234pub fn reconcile_all(
247 repo_paths: &[PathBuf],
248 obsidian_root_path_dir: &Path,
249 obsidian_commit_path: &Path,
250 template_commit_date_path: &str,
251 template_commit_datetime: &str,
252 excluded_repos: &[String],
253 since: Option<DateTime<Utc>>,
254) -> Result<Vec<ReconcileReport>, Box<dyn Error>> {
255 let mut reports = Vec::new();
256
257 for repo_path in repo_paths {
258 let outcome = match Repository::discover(repo_path) {
259 Ok(repo) => reconcile_repo(
260 &repo,
261 obsidian_root_path_dir,
262 obsidian_commit_path,
263 template_commit_date_path,
264 template_commit_datetime,
265 excluded_repos,
266 since,
267 ),
268 Err(error) => Err(error.into()),
269 };
270
271 match outcome {
272 Ok(report) => reports.push(report),
273 Err(error) => {
274 warn!(
275 "[reconcile_all()]: skipping {}: {error}",
276 repo_path.display()
277 );
278 eprintln!(
282 "rusty-commit-saver: skipping {}: {error}",
283 repo_path.display()
284 );
285 }
286 }
287 }
288
289 Ok(reports)
290}
291
292#[cfg(test)]
293#[cfg_attr(coverage_nightly, coverage(off))]
294mod reconcile_tests {
295 use super::*;
296 use chrono::TimeZone;
297 use git2::Signature;
298 use git2::Time;
299 use tempfile::TempDir;
300
301 const DATE_TEMPLATE: &str = "%Y/%m-%B/%F.md";
302 const TIME_TEMPLATE: &str = "%H:%M:%S";
303
304 struct Fixture {
307 _dir: TempDir,
308 repo: Repository,
309 }
310
311 impl Fixture {
312 fn new(origin: &str) -> Self {
313 let dir = tempfile::tempdir().expect("tempdir");
314 let repo = Repository::init(dir.path()).expect("init");
315 repo.remote("origin", origin).expect("remote");
316
317 Fixture { _dir: dir, repo }
318 }
319
320 fn commit_at(&self, message: &str, epoch_seconds: i64) -> String {
322 let when = Time::new(epoch_seconds, 0);
323 let who = Signature::new("Test User", "test@example.com", &when).expect("signature");
324
325 let tree_id = self
326 .repo
327 .index()
328 .expect("index")
329 .write_tree()
330 .expect("tree");
331 let tree = self.repo.find_tree(tree_id).expect("find tree");
332
333 let parents = match self.repo.head().ok().and_then(|h| h.peel_to_commit().ok()) {
334 Some(parent) => vec![parent],
335 None => Vec::new(),
336 };
337 let parent_refs: Vec<&git2::Commit<'_>> = parents.iter().collect();
338
339 self.repo
340 .commit(Some("HEAD"), &who, &who, message, &tree, &parent_refs)
341 .expect("commit")
342 .to_string()
343 }
344 }
345
346 fn vault() -> TempDir {
347 tempfile::tempdir().expect("vault tempdir")
348 }
349
350 fn run(
351 fixture: &Fixture,
352 root: &Path,
353 excluded: &[String],
354 since: Option<DateTime<Utc>>,
355 ) -> ReconcileReport {
356 reconcile_repo(
357 &fixture.repo,
358 root,
359 Path::new("Diaries/Commits"),
360 DATE_TEMPLATE,
361 TIME_TEMPLATE,
362 excluded,
363 since,
364 )
365 .expect("reconcile should succeed")
366 }
367
368 fn note_for(root: &Path, year: i32, month: u32, day: u32) -> PathBuf {
369 let stamp = Utc
370 .with_ymd_and_hms(year, month, day, 0, 0, 0)
371 .single()
372 .expect("a real date");
373
374 root.join("Diaries")
375 .join("Commits")
376 .join(stamp.format(DATE_TEMPLATE).to_string())
377 }
378
379 const MARCH_FIFTH: i64 = 1_709_629_200;
381 const APRIL_ELEVENTH: i64 = 1_712_860_200;
382
383 #[test]
384 fn hashes_in_note_reads_the_last_column_only() {
385 let note = "\
386| FOLDER | TIME | COMMIT MESSAGE | REPOSITORY URL | BRANCH | COMMIT HASH |
387|--------|------|----------------|----------------|--------|-------------|
388| /src/x | 09:00:00 | feat: a thing | https://h/x.git | main | abc123def456 |
389| /src/x | 10:00:00 | fix: another | https://h/x.git | main | 0123456789abcdef |
390";
391
392 let hashes = hashes_in_note(note);
393
394 assert_eq!(
395 hashes.len(),
396 2,
397 "both rows, and neither header line: {hashes:?}"
398 );
399 assert!(hashes.contains("abc123def456"));
400 assert!(hashes.contains("0123456789abcdef"));
401 }
402
403 #[test]
404 fn hashes_in_note_survives_an_escaped_pipe_in_the_message() {
405 let note =
409 "| /src/x | 09:00:00 | fix: a \\| b \\| c | https://h/x.git | main | deadbeef1234 |\n";
410
411 let hashes = hashes_in_note(note);
412
413 assert_eq!(hashes.len(), 1, "the escaped pipes must not confuse it");
414 assert!(hashes.contains("deadbeef1234"));
415 }
416
417 #[test]
418 fn hashes_in_note_recognises_a_sha256_length_id() {
419 let sha256 = "a".repeat(64);
425 let note = format!("| /src/x | 09:00:00 | feat: a thing | u | main | {sha256} |\n");
426
427 assert!(hashes_in_note(¬e).contains(&sha256));
428 }
429
430 #[test]
431 fn hashes_in_note_ignores_prose_and_frontmatter() {
432 let note = "---\ncategory: diary\n---\n\n# 2024-03-05\n\nsome prose\n";
433
434 assert!(hashes_in_note(note).is_empty());
435 }
436
437 #[test]
438 fn a_repo_with_no_note_gets_every_row() {
439 let fixture = Fixture::new("git@github.com:chess-seventh/example.git");
440 fixture.commit_at("feat: one", MARCH_FIFTH);
441 fixture.commit_at("feat: two", MARCH_FIFTH + 60);
442 let root = vault();
443
444 let report = run(&fixture, root.path(), &[], None);
445
446 assert_eq!(report.scanned, 2);
447 assert_eq!(report.appended, 2);
448 assert_eq!(report.already_present, 0);
449
450 let note = fs::read_to_string(note_for(root.path(), 2024, 3, 5)).expect("note written");
451 assert!(note.contains("feat: one"), "first row missing: {note}");
452 assert!(note.contains("feat: two"), "second row missing: {note}");
453 }
454
455 #[test]
456 fn a_second_pass_appends_nothing() {
457 let fixture = Fixture::new("git@github.com:chess-seventh/example.git");
462 fixture.commit_at("feat: one", MARCH_FIFTH);
463 fixture.commit_at("feat: two", MARCH_FIFTH + 60);
464 let root = vault();
465
466 run(&fixture, root.path(), &[], None);
467 let after = fs::read_to_string(note_for(root.path(), 2024, 3, 5)).expect("note");
468
469 let second = run(&fixture, root.path(), &[], None);
470
471 assert_eq!(second.appended, 0, "a second pass must append nothing");
472 assert_eq!(second.already_present, 2);
473 assert_eq!(
474 fs::read_to_string(note_for(root.path(), 2024, 3, 5)).expect("note"),
475 after,
476 "the note must be byte-identical after a second pass"
477 );
478 }
479
480 #[test]
481 fn a_row_the_hook_already_wrote_is_not_written_again() {
482 let fixture = Fixture::new("git@github.com:chess-seventh/example.git");
486 let sha = fixture.commit_at("feat: one", MARCH_FIFTH);
487 let root = vault();
488
489 let note = note_for(root.path(), 2024, 3, 5);
490 fs::create_dir_all(note.parent().expect("parent")).expect("dirs");
491 fs::write(
492 ¬e,
493 format!("| /elsewhere | 09:00:00 | feat: one | u | main | {sha} |\n"),
494 )
495 .expect("seed the note");
496
497 let report = run(&fixture, root.path(), &[], None);
498
499 assert_eq!(report.appended, 0, "the hook's row already covers it");
500 assert_eq!(report.already_present, 1);
501 assert_eq!(
502 fs::read_to_string(¬e).expect("note").lines().count(),
503 1,
504 "the note must still hold exactly one row"
505 );
506 }
507
508 #[test]
509 fn existing_content_is_never_rewritten() {
510 let fixture = Fixture::new("git@github.com:chess-seventh/example.git");
514 fixture.commit_at("feat: one", MARCH_FIFTH);
515 let root = vault();
516
517 let note = note_for(root.path(), 2024, 3, 5);
518 fs::create_dir_all(note.parent().expect("parent")).expect("dirs");
519 let hand_written =
520 "# a note I wrote by hand\n\n| /x | 00:00:00 | older | u | main | 1111111 |\n";
521 fs::write(¬e, hand_written).expect("seed");
522
523 run(&fixture, root.path(), &[], None);
524
525 let after = fs::read_to_string(¬e).expect("note");
526 assert!(
527 after.starts_with(hand_written),
528 "existing bytes must survive untouched: {after}"
529 );
530 assert!(after.contains("feat: one"), "the new row must be appended");
531 }
532
533 #[test]
534 fn a_row_lands_in_the_note_for_its_own_date() {
535 let fixture = Fixture::new("git@github.com:chess-seventh/example.git");
538 fixture.commit_at("feat: march", MARCH_FIFTH);
539 fixture.commit_at("feat: april", APRIL_ELEVENTH);
540 let root = vault();
541
542 run(&fixture, root.path(), &[], None);
543
544 let march = fs::read_to_string(note_for(root.path(), 2024, 3, 5)).expect("march note");
545 let april = fs::read_to_string(note_for(root.path(), 2024, 4, 11)).expect("april note");
546
547 assert!(march.contains("feat: march"));
548 assert!(!march.contains("feat: april"), "april leaked into march");
549 assert!(april.contains("feat: april"));
550 assert!(!april.contains("feat: march"), "march leaked into april");
551
552 let today = note_for(
553 root.path(),
554 Utc::now().format("%Y").to_string().parse().expect("year"),
555 Utc::now().format("%m").to_string().parse().expect("month"),
556 Utc::now().format("%d").to_string().parse().expect("day"),
557 );
558 assert!(
559 !today.exists(),
560 "nothing may be written to today's note: {}",
561 today.display()
562 );
563 }
564
565 #[test]
566 fn an_excluded_repo_is_never_read_or_written() {
567 let fixture = Fixture::new("git@github.com:chess-seventh/claude-src.git");
571 fixture.commit_at("chore(mailbox): a baton", MARCH_FIFTH);
572 let root = vault();
573
574 let report = run(&fixture, root.path(), &["claude-src".to_string()], None);
575
576 assert!(report.excluded, "the repo must be reported as excluded");
577 assert_eq!(report.scanned, 0, "an excluded repo is not even walked");
578 assert_eq!(report.appended, 0);
579 assert!(
580 !root.path().join("Diaries").exists(),
581 "an excluded repo must create no vault output at all"
582 );
583 }
584
585 #[test]
586 fn the_exclude_list_matches_the_origin_not_the_directory() {
587 let fixture = Fixture::new("git@github.com:chess-seventh/claude-src.git");
591 fixture.commit_at("chore(mailbox): a baton", MARCH_FIFTH);
592 let root = vault();
593
594 let report = run(&fixture, root.path(), &["claude-src".to_string()], None);
595
596 assert_eq!(report.repository, "claude-src");
597 assert!(report.excluded);
598 }
599
600 #[test]
601 fn since_leaves_older_commits_alone() {
602 let fixture = Fixture::new("git@github.com:chess-seventh/example.git");
603 fixture.commit_at("feat: march", MARCH_FIFTH);
604 fixture.commit_at("feat: april", APRIL_ELEVENTH);
605 let root = vault();
606
607 let floor = DateTime::from_timestamp(APRIL_ELEVENTH - 3600, 0).expect("a real instant");
608 let report = run(&fixture, root.path(), &[], Some(floor));
609
610 assert_eq!(report.scanned, 1, "only the april commit is in range");
611 assert_eq!(report.appended, 1);
612 assert!(
613 !note_for(root.path(), 2024, 3, 5).exists(),
614 "a commit below the floor must not create its note"
615 );
616 }
617
618 #[test]
619 fn reconcile_all_reports_a_bad_path_and_carries_on() {
620 let fixture = Fixture::new("git@github.com:chess-seventh/example.git");
621 fixture.commit_at("feat: one", MARCH_FIFTH);
622 let root = vault();
623 let not_a_repo = tempfile::tempdir().expect("tempdir");
624
625 let reports = reconcile_all(
626 &[
627 not_a_repo.path().to_path_buf(),
628 fixture.repo.workdir().expect("workdir").to_path_buf(),
629 ],
630 root.path(),
631 Path::new("Diaries/Commits"),
632 DATE_TEMPLATE,
633 TIME_TEMPLATE,
634 &[],
635 None,
636 )
637 .expect("reconcile_all should not fail on one bad path");
638
639 assert_eq!(reports.len(), 1, "the good repository is still reconciled");
640 assert_eq!(reports[0].appended, 1);
641 }
642}