1use std::path::Path;
9
10use seshat_core::BranchId;
11use seshat_storage::BranchRepository;
12
13pub 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
40pub 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#[derive(Debug, Clone, PartialEq, Eq)]
86pub enum FreshnessCheck {
87 Stale {
95 old_commit: Option<String>,
96 new_commit: String,
97 },
98 UpToDate,
100 GitUnavailable,
104}
105
106pub 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 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 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 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 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 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 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 let db = Database::open(":memory:").expect("open DB");
386 let branch_repo = SqliteBranchRepository::new(db.connection().clone());
387 let branch = BranchId::from("main");
388 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}