ckg_git/lib.rs
1//! Git helpers used by the indexer and blast-radius recency weighting.
2
3use std::path::Path;
4
5use ckg_core::Result;
6
7/// Resolve HEAD sha for the repo at `path`, or empty string if not a repo.
8///
9/// Errors are demoted to empty string so non-git directories index cleanly.
10/// We log at `debug` so operators can distinguish "not a repo" (expected)
11/// from "broken refs / unborn HEAD / detached HEAD" (worth investigating)
12/// when a repo unexpectedly has no recency weighting.
13///
14/// ## M4: `open` not `discover`
15///
16/// Uses `Repository::open` instead of `Repository::discover` so that
17/// only the exact directory `path` is opened as a git repo. `discover`
18/// walks parent directories: calling it on a subdirectory of a repo
19/// returns the repo root, so the recency weighting would silently apply
20/// to a different (larger) scope. `open` returns an error for
21/// non-root paths, which we demote to empty-string as for non-repos.
22///
23/// ## L8: `Result<String>` is retained
24///
25/// The function currently always returns `Ok(…)` — errors are demoted
26/// to `Ok(String::new())`. The `Result` wrapper is kept for two reasons:
27/// (a) callers already propagate it with `?`, changing the return type
28/// is a silent breaking change; (b) a future version may propagate IO
29/// errors (e.g. permission denied reading `.git/HEAD`) to let callers
30/// decide the error policy.
31pub fn head_sha(path: &Path) -> Result<String> {
32 match git2::Repository::open(path) {
33 Ok(repo) => {
34 // Post-check M4: verify workdir matches `path` exactly.
35 // `open` on a `.git` directory returns a bare-like handle
36 // with no workdir; `open` on the repo root returns the root.
37 if let Some(workdir) = repo.workdir() {
38 let canonical_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
39 let canonical_workdir = workdir.canonicalize().unwrap_or_else(|_| workdir.to_path_buf());
40 if canonical_path != canonical_workdir {
41 tracing::debug!(
42 "head_sha: {path} opened repo with different workdir {workdir}; skipping",
43 path = path.display(),
44 workdir = workdir.display(),
45 );
46 return Ok(String::new());
47 }
48 }
49 match repo.head().and_then(|h| h.peel_to_commit()) {
50 Ok(commit) => Ok(commit.id().to_string()),
51 Err(e) => {
52 tracing::debug!(
53 "head_sha: repo at {} has no peelable HEAD: {e}",
54 path.display()
55 );
56 Ok(String::new())
57 }
58 }
59 }
60 Err(e) => {
61 tracing::debug!("head_sha: not a git repo at {}: {e}", path.display());
62 Ok(String::new())
63 }
64 }
65}