Skip to main content

seshat_scanner/
git_utils.rs

1//! Git utility functions for submodule operations and project freshness.
2//!
3//! Provides helpers for extracting git metadata (HEAD commit hash) from any
4//! git working tree — submodule, project root, worktree — and recording the
5//! commit SHA reached at scan-completion time so subsequent startups can
6//! detect divergence (US-009).
7
8use std::path::Path;
9
10use seshat_core::BranchId;
11use seshat_storage::BranchRepository;
12
13/// Get the current commit hash (HEAD) of the git repository at `path`.
14///
15/// Opens `path` as a git repository (works with normal repos, worktrees, and
16/// submodules — anywhere a `.git` file or directory is reachable) and reads
17/// the HEAD commit object ID.
18///
19/// Returns `None` if:
20/// - The path is not a git repository
21/// - The repository has no commits (freshly init'd)
22/// - Any git operation fails
23///
24/// # Examples
25///
26/// ```no_run
27/// use std::path::Path;
28/// use seshat_scanner::get_head_commit;
29///
30/// if let Some(head) = get_head_commit(Path::new(".")) {
31///     println!("HEAD: {head}");
32/// }
33/// ```
34pub fn get_head_commit(path: &Path) -> Option<String> {
35    let repo = gix::open(path).ok()?;
36    let head = repo.head_commit().ok()?;
37    Some(head.id().to_string())
38}
39
40/// Record `branch_id`'s `last_scanned_commit` to the current `git rev-parse
41/// HEAD` of `root`, after a successful scan/sync.
42///
43/// Behaviour:
44/// - When `root` resolves to a real git repo, calls
45///   [`BranchRepository::set_last_scanned_commit`] with the HEAD commit hash.
46/// - When git is unavailable (no `.git`, empty repo, gix open failure), this
47///   is a silent no-op with a `debug!` trace — the `branches.last_scanned_commit`
48///   column simply stays `NULL` for that branch (US-009 git-unavailable case).
49/// - Storage errors during the write are logged at `warn!` and swallowed:
50///   the scan/sync that just succeeded should not regress because the
51///   freshness sentinel could not be persisted.
52pub fn record_branch_scan_complete<R: BranchRepository>(
53    branch_repo: &R,
54    root: &Path,
55    branch_id: &BranchId,
56) {
57    match get_head_commit(root) {
58        Some(head) => {
59            if let Err(e) = branch_repo.set_last_scanned_commit(branch_id, &head) {
60                tracing::warn!(
61                    error = %e,
62                    branch = %branch_id.0,
63                    "failed to record last_scanned_commit; freshness check may be stale"
64                );
65            } else {
66                tracing::debug!(
67                    branch = %branch_id.0,
68                    head = %head,
69                    "recorded last_scanned_commit"
70                );
71            }
72        }
73        None => {
74            tracing::debug!(
75                root = %root.display(),
76                branch = %branch_id.0,
77                "git unavailable; skipping last_scanned_commit update"
78            );
79        }
80    }
81}
82
83/// Outcome of comparing a branch's stored `last_scanned_commit` sentinel
84/// against the current `git rev-parse HEAD` of `root` (US-010).
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub enum FreshnessCheck {
87    /// HEAD differs from the stored sentinel — an incremental sync should run.
88    ///
89    /// `old_commit` is the previously recorded HEAD; it is `None` when the
90    /// branch has never been scanned (e.g. a pre-US-009 DB or a fresh branch
91    /// row created without a recorded HEAD), and `Some(...)` otherwise. The
92    /// hash form is suitable to feed back into [`get_head_commit`]'s gix-tree
93    /// resolution as the old-side of a tree diff.
94    Stale {
95        old_commit: Option<String>,
96        new_commit: String,
97    },
98    /// Sentinel matches HEAD — no sync is needed.
99    UpToDate,
100    /// Git is unavailable for `root` (no `.git`, empty repo, gix open failed).
101    /// Per the PRD's git-optional fallback, freshness checks short-circuit
102    /// and no sync is triggered.
103    GitUnavailable,
104}
105
106/// Compare `branch_id`'s stored `last_scanned_commit` to the on-disk HEAD of
107/// the git working tree at `root` and return a [`FreshnessCheck`] result.
108///
109/// Used by `seshat serve` (US-010) and `seshat review` (US-011) at startup
110/// to decide whether to trigger an incremental sync before serving stale
111/// data to the user.
112///
113/// Storage errors when reading the sentinel are logged at `warn!` and treated
114/// as "no recorded sentinel" (the helper returns `Stale { old_commit: None, .. }`
115/// when HEAD is reachable, or `GitUnavailable` when it is not). This matches
116/// the contract of [`record_branch_scan_complete`] which also swallows
117/// storage errors so freshness machinery never crashes a startup.
118pub fn check_branch_freshness<R: BranchRepository>(
119    branch_repo: &R,
120    root: &Path,
121    branch_id: &BranchId,
122) -> FreshnessCheck {
123    let new_commit = match get_head_commit(root) {
124        Some(c) => c,
125        None => return FreshnessCheck::GitUnavailable,
126    };
127    let old_commit = match branch_repo.get_last_scanned_commit(branch_id) {
128        Ok(c) => c,
129        Err(e) => {
130            tracing::warn!(
131                error = %e,
132                branch = %branch_id.0,
133                "failed to read last_scanned_commit; treating as never-scanned"
134            );
135            None
136        }
137    };
138    match &old_commit {
139        Some(prev) if *prev == new_commit => FreshnessCheck::UpToDate,
140        _ => FreshnessCheck::Stale {
141            old_commit,
142            new_commit,
143        },
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use std::fs;
151    use std::process::{Command, Stdio};
152    use tempfile::tempdir;
153
154    use seshat_storage::{BranchRepository, Database, SqliteBranchRepository};
155
156    /// Initialise a git repo at `path` with one commit and return the HEAD SHA.
157    fn init_git_repo_with_commit(path: &Path) -> String {
158        Command::new("git")
159            .args(["init", "-b", "main"])
160            .current_dir(path)
161            .stdout(Stdio::null())
162            .stderr(Stdio::null())
163            .status()
164            .expect("git init");
165        Command::new("git")
166            .args(["config", "user.email", "test@seshat.dev"])
167            .current_dir(path)
168            .stdout(Stdio::null())
169            .status()
170            .expect("git config email");
171        Command::new("git")
172            .args(["config", "user.name", "Seshat Test"])
173            .current_dir(path)
174            .stdout(Stdio::null())
175            .status()
176            .expect("git config name");
177        fs::write(path.join("README.md"), "# fixture").expect("write readme");
178        Command::new("git")
179            .args(["add", "."])
180            .current_dir(path)
181            .stdout(Stdio::null())
182            .status()
183            .expect("git add");
184        Command::new("git")
185            .args(["commit", "-m", "initial commit"])
186            .current_dir(path)
187            .stdout(Stdio::null())
188            .stderr(Stdio::null())
189            .status()
190            .expect("git commit");
191
192        let out = Command::new("git")
193            .args(["rev-parse", "HEAD"])
194            .current_dir(path)
195            .output()
196            .expect("git rev-parse HEAD");
197        String::from_utf8(out.stdout)
198            .expect("rev-parse output utf8")
199            .trim()
200            .to_owned()
201    }
202
203    #[test]
204    fn returns_none_for_non_git_directory() {
205        let dir = tempdir().expect("create temp dir");
206        assert!(get_head_commit(dir.path()).is_none());
207        assert!(get_head_commit(dir.path()).is_none());
208    }
209
210    #[test]
211    fn returns_none_for_nonexistent_path() {
212        assert!(get_head_commit(Path::new("/tmp/does-not-exist-seshat-test")).is_none());
213        assert!(get_head_commit(Path::new("/tmp/does-not-exist-seshat-test")).is_none());
214    }
215
216    #[test]
217    fn returns_none_for_empty_git_repo() {
218        let dir = tempdir().expect("create temp dir");
219        // Create .git dir but no commits
220        fs::create_dir(dir.path().join(".git")).expect("create .git");
221        assert!(get_head_commit(dir.path()).is_none());
222        assert!(get_head_commit(dir.path()).is_none());
223    }
224
225    #[test]
226    fn get_head_commit_returns_hash_for_real_git_repo() {
227        let dir = tempdir().expect("create temp dir");
228        let expected = init_git_repo_with_commit(dir.path());
229        let hash = get_head_commit(dir.path()).expect("HEAD commit hash");
230        assert_eq!(hash, expected, "gix HEAD should match git rev-parse HEAD");
231        assert_eq!(hash.len(), 40, "SHA-1 hash should be 40 hex chars");
232        assert!(
233            hash.chars().all(|c| c.is_ascii_hexdigit()),
234            "hash should be hex: {hash}"
235        );
236    }
237
238    #[test]
239    fn record_branch_scan_complete_writes_head_to_branches_table() {
240        let dir = tempdir().expect("create temp dir");
241        let expected_head = init_git_repo_with_commit(dir.path());
242
243        let db = Database::open(":memory:").expect("open DB");
244        let branch_repo = SqliteBranchRepository::new(db.connection().clone());
245        let branch = BranchId::from("main");
246        branch_repo
247            .ensure_branch_exists(&branch)
248            .expect("ensure branch exists");
249
250        record_branch_scan_complete(&branch_repo, dir.path(), &branch);
251
252        let stored = branch_repo
253            .get_last_scanned_commit(&branch)
254            .expect("get last_scanned_commit");
255        assert_eq!(
256            stored,
257            Some(expected_head),
258            "branches.last_scanned_commit must match git rev-parse HEAD"
259        );
260    }
261
262    #[test]
263    fn record_branch_scan_complete_is_silent_noop_when_git_unavailable() {
264        let dir = tempdir().expect("create temp dir");
265        // No .git here — record_branch_scan_complete must NOT write a sentinel.
266
267        let db = Database::open(":memory:").expect("open DB");
268        let branch_repo = SqliteBranchRepository::new(db.connection().clone());
269        let branch = BranchId::from("main");
270        branch_repo
271            .ensure_branch_exists(&branch)
272            .expect("ensure branch exists");
273
274        record_branch_scan_complete(&branch_repo, dir.path(), &branch);
275
276        let stored = branch_repo
277            .get_last_scanned_commit(&branch)
278            .expect("get last_scanned_commit");
279        assert_eq!(
280            stored, None,
281            "branches.last_scanned_commit must stay NULL when git is unavailable"
282        );
283    }
284
285    /// Initialise a git repo at `path`, make a single commit (returning its
286    /// SHA), then make a second commit with a follow-up file (returning its
287    /// SHA). Used by the freshness-check tests to produce a real two-commit
288    /// history to compare against.
289    fn init_git_repo_with_two_commits(path: &Path) -> (String, String) {
290        let head1 = init_git_repo_with_commit(path);
291        fs::write(path.join("CHANGES.md"), "# changes").expect("write CHANGES.md");
292        Command::new("git")
293            .args(["add", "."])
294            .current_dir(path)
295            .stdout(Stdio::null())
296            .status()
297            .expect("git add second");
298        Command::new("git")
299            .args(["commit", "-m", "follow-up commit"])
300            .current_dir(path)
301            .stdout(Stdio::null())
302            .stderr(Stdio::null())
303            .status()
304            .expect("git commit second");
305        let out = Command::new("git")
306            .args(["rev-parse", "HEAD"])
307            .current_dir(path)
308            .output()
309            .expect("git rev-parse HEAD second");
310        let head2 = String::from_utf8(out.stdout)
311            .expect("rev-parse output utf8 second")
312            .trim()
313            .to_owned();
314        assert_ne!(head1, head2, "two commits must have distinct SHAs");
315        (head1, head2)
316    }
317
318    #[test]
319    fn check_branch_freshness_returns_up_to_date_when_sentinel_matches_head() {
320        let dir = tempdir().expect("create temp dir");
321        let head = init_git_repo_with_commit(dir.path());
322
323        let db = Database::open(":memory:").expect("open DB");
324        let branch_repo = SqliteBranchRepository::new(db.connection().clone());
325        let branch = BranchId::from("main");
326        branch_repo
327            .set_last_scanned_commit(&branch, &head)
328            .expect("set sentinel");
329
330        let result = check_branch_freshness(&branch_repo, dir.path(), &branch);
331        assert_eq!(result, FreshnessCheck::UpToDate);
332    }
333
334    #[test]
335    fn check_branch_freshness_returns_stale_when_head_advances() {
336        let dir = tempdir().expect("create temp dir");
337        let (head1, head2) = init_git_repo_with_two_commits(dir.path());
338
339        let db = Database::open(":memory:").expect("open DB");
340        let branch_repo = SqliteBranchRepository::new(db.connection().clone());
341        let branch = BranchId::from("main");
342        // Pin the sentinel at the OLDER commit; HEAD now points at the newer one.
343        branch_repo
344            .set_last_scanned_commit(&branch, &head1)
345            .expect("set sentinel at head1");
346
347        let result = check_branch_freshness(&branch_repo, dir.path(), &branch);
348        assert_eq!(
349            result,
350            FreshnessCheck::Stale {
351                old_commit: Some(head1),
352                new_commit: head2,
353            },
354            "sentinel at head1 with HEAD at head2 must be Stale"
355        );
356    }
357
358    #[test]
359    fn check_branch_freshness_returns_stale_with_none_old_commit_when_never_scanned() {
360        let dir = tempdir().expect("create temp dir");
361        let head = init_git_repo_with_commit(dir.path());
362
363        let db = Database::open(":memory:").expect("open DB");
364        let branch_repo = SqliteBranchRepository::new(db.connection().clone());
365        let branch = BranchId::from("main");
366        // Branch row is NOT registered with a sentinel — emulates a pre-US-009
367        // DB or a fresh branch that has never had its HEAD recorded.
368
369        let result = check_branch_freshness(&branch_repo, dir.path(), &branch);
370        assert_eq!(
371            result,
372            FreshnessCheck::Stale {
373                old_commit: None,
374                new_commit: head,
375            },
376            "no recorded sentinel + reachable HEAD must be Stale with old_commit=None"
377        );
378    }
379
380    #[test]
381    fn check_branch_freshness_returns_git_unavailable_for_non_git_directory() {
382        let dir = tempdir().expect("create temp dir");
383        // No `.git` here.
384
385        let db = Database::open(":memory:").expect("open DB");
386        let branch_repo = SqliteBranchRepository::new(db.connection().clone());
387        let branch = BranchId::from("main");
388        // Even with a recorded sentinel, git-unavailable wins.
389        branch_repo
390            .set_last_scanned_commit(&branch, "deadbeefcafebabedeadbeefcafebabedeadbeef")
391            .expect("set sentinel");
392
393        let result = check_branch_freshness(&branch_repo, dir.path(), &branch);
394        assert_eq!(result, FreshnessCheck::GitUnavailable);
395    }
396}