rusty_commit_saver/
reconcile.rs

1//! The `git log` backstop for the commit journal (D-98).
2//!
3//! The post-commit hook only writes the journal on a box that mounts the
4//! vault. Everywhere else the row is lost, and once the config was copied onto
5//! those boxes it started being lost *silently* — which is worse, because a
6//! journal that quietly stops looks exactly like a quiet week.
7//!
8//! This module is the other half of the ruling: a pass that reads the history
9//! reachable from a repository's `HEAD` and appends whatever row the day note
10//! is missing. It needs no broker and no network, so it works on the day the
11//! wire is down and on the day the wire does not exist yet.
12//!
13//! # What it will not do
14//!
15//! It **only ever appends**. It never rewrites, reorders or reformats a row
16//! that is already in a note. The day notes are hand-readable files a human
17//! keeps, so a backfill that touched existing rows would be a far worse defect
18//! than a missing one.
19//!
20//! It sees **only what `HEAD` reaches**, which is narrower than "the
21//! repository's history" and is stated here because the difference bites in
22//! exactly the workflow this fleet uses: a commit on an unmerged lane branch is
23//! not journalled, and if that branch is squash-merged it never becomes
24//! reachable at all. Point a pass at each worktree, not only at the main clone.
25//! Widening it to every local branch is a decision about what belongs in the
26//! journal, not a bug fix, so it is not taken here.
27//!
28//! Identity is the **commit hash column**, which is why a row written by the
29//! hook and a row this pass would have written collapse to one: whichever
30//! arrives first wins, and the other is recognised as already present. That is
31//! the dedup rule D-98 named as the thing that must be exactly right.
32
33use 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/// What one reconcile pass over one repository did.
55#[derive(Debug, Default, Clone, PartialEq, Eq)]
56pub struct ReconcileReport {
57    /// The repository's canonical name, as the exclude list spells it.
58    pub repository: String,
59
60    /// The repository is in the configured exclude list, so nothing was read
61    /// and nothing was written.
62    pub excluded: bool,
63
64    /// Commits walked. Zero on an excluded repository.
65    pub scanned: usize,
66
67    /// Rows appended to a day note by this pass.
68    pub appended: usize,
69
70    /// Commits whose row was already in its day note — the hook got there
71    /// first, or an earlier pass did.
72    pub already_present: usize,
73}
74
75/// Every commit hash already recorded as a row in a day note.
76///
77/// The hash is the row's last column, and a commit id contains no `|`, so the
78/// text after the final separator is read directly. Anything that is not
79/// plausibly an object id — the table header, the `|---|` rule, a stray line —
80/// is dropped, which is what keeps `COMMIT HASH` out of the set.
81#[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
91/// The final cell of a Markdown table row, or `None` if the line is not one.
92///
93/// Splitting from the right matters: a commit message may carry escaped pipes
94/// in an earlier column, and reading from the left would have to understand
95/// that escaping. The last column never can, so it does not have to.
96fn 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
103/// Whether a cell could be a git object id.
104///
105/// Deliberately permissive about length so an abbreviated hash still matches —
106/// this decides what is *already recorded*, and a false negative would append
107/// a duplicate row, which is the one outcome that matters.
108///
109/// There is a floor and **no ceiling**, on purpose. A ceiling of 40 reads as
110/// correct against SHA-1 and encodes an assumption this function has no reason
111/// to make: a SHA-256 repository writes 64-character ids, none of which would
112/// match. The floor alone already excludes the table header and the `|---|`
113/// rule, which is all a bound was ever there to do.
114///
115/// Measured rather than asserted, because the obvious reading overstates it:
116/// the git2 this crate builds against refuses to open a SHA-256 repository at
117/// all (`unknown object format 'sha256'`), so the duplicate-for-ever outcome is
118/// not reachable today. The ceiling protected nothing, so removing it costs
119/// nothing and stops the assumption outliving the reason for it.
120fn looks_like_object_id(value: &str) -> bool {
121    value.len() >= 7 && value.chars().all(|character| character.is_ascii_hexdigit())
122}
123
124/// Appends every row a repository's day notes are missing, for the history
125/// `HEAD` can reach.
126///
127/// Walks that history oldest commit first, and for each commit appends a row to
128/// the note for **that commit's own date** unless the note already carries its
129/// hash. A commit `HEAD` cannot reach is not seen at all — see the module doc.
130///
131/// # Arguments
132///
133/// * `repo` - the repository to reconcile
134/// * `obsidian_root_path_dir` - the vault root
135/// * `obsidian_commit_path` - the commits subdirectory under the root
136/// * `template_commit_date_path` - chrono format for the note's path
137/// * `template_commit_datetime` - chrono format for the row's TIME column
138/// * `excluded_repos` - repositories to skip entirely
139/// * `since` - when set, commits older than this are not walked
140///
141/// # Errors
142///
143/// Returns an error if the history cannot be walked, if a note cannot be
144/// created, or if a note cannot be read or appended to.
145pub 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    // The hook reports the directory it ran in. This pass runs from wherever
171    // its timer put it, so the honest FOLDER is the repository's own work tree.
172    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    // One entry per day note touched, so a note is read once however many of
181    // its rows this pass appends.
182    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(&note)?)
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(&note, &mut saver)?;
220        saver.append_row_to_diary(&note, &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
234/// Reconciles every repository in `repo_paths`, in order.
235///
236/// A repository that cannot be opened or walked is reported on stderr and the
237/// pass continues to the next one. A reconcile that abandoned the remaining
238/// repositories because one of them is broken would leave the journal in a
239/// worse state than not running at all, and the next run would hit the same
240/// repository and stop in the same place.
241///
242/// # Errors
243///
244/// Never returns an error for a single bad repository; the `Err` arm is
245/// reserved for a caller-level fault.
246pub 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                // Also on stderr: the reconciler runs from a timer, where
279                // env_logger caps the level at Error without RUST_LOG and the
280                // warning would be swallowed.
281                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    /// A repository whose commits sit at chosen instants, so a test can assert
305    /// which day note a row lands in without depending on the clock.
306    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        /// Commits an empty tree at `epoch_seconds`, returning the new id.
321        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    // 2024-03-05 09:00:00 UTC and 2024-04-11 18:30:00 UTC.
380    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        // The writer escapes pipes in the message column. Reading the row from
406        // the left would have to understand that; reading the last cell does
407        // not, and this is the row shape that proves it.
408        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        // A ceiling of 40 encodes SHA-1 into a function that has no reason to
420        // know about it. Measured: the git2 this crate builds against will not
421        // open a SHA-256 repository at all, so nothing is broken today - what
422        // this pins is that the bound does not quietly become load-bearing the
423        // day that changes.
424        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(&note).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        // MUST-PROVE: running the reconciler twice produces exactly one row per
458        // commit. This is the dedup rule with the wire taken out of it — the
459        // same identity check that makes the hook and this pass collapse to one
460        // row when both run.
461        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        // The dedup rule from the other direction: the row is in the note but
483        // this pass never wrote it, exactly as it would be on the vault box
484        // where the hook runs too.
485        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            &note,
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(&note).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        // MUST-PROVE: the pass only ever APPENDS. The day notes are Franci's,
511        // and a backfill that reformatted or reordered what is already there
512        // would be a far worse defect than a missing row.
513        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(&note, hand_written).expect("seed");
522
523        run(&fixture, root.path(), &[], None);
524
525        let after = fs::read_to_string(&note).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        // MUST-PROVE: never today's note. Without this the first backfill
536        // collapses a month of history into whatever note the clock names.
537        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        // MUST-PROVE: the exclusion was enforced only on the hook path. A
568        // reconciler that ignored it would journal every claude-src baton, which
569        // is precisely what the exclude list exists to prevent.
570        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        // The worktree a lane builds in is named after the lane, so matching on
588        // the directory would let every excluded repo back in through its own
589        // worktrees.
590        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}