Skip to main content

repograph_core/
git.rs

1//! `git2`-backed introspection helpers.
2
3use std::path::{Path, PathBuf};
4
5use serde::Serialize;
6
7use crate::error::RepographError;
8
9/// Verify that `path` is a git repository, returning its canonical absolute
10/// form on success. Symlinks are resolved; relative inputs are absolutized.
11///
12/// # Errors
13///
14/// Returns [`RepographError::NotFound`] when `path` does not exist on disk, or
15/// [`RepographError::GitOpen`] when it exists but is not a git repository.
16pub fn validate_git_repo(path: &Path) -> Result<PathBuf, RepographError> {
17    let canonical = match crate::path::canonicalize(path) {
18        Ok(p) => p,
19        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
20            return Err(RepographError::NotFound {
21                kind: "path",
22                name: path.display().to_string(),
23            });
24        }
25        Err(e) => return Err(e.into()),
26    };
27
28    git2::Repository::open(&canonical).map_err(|source| RepographError::GitOpen {
29        path: canonical.clone(),
30        source,
31    })?;
32
33    Ok(canonical)
34}
35
36/// Coarse classification of a registered repository's runtime state. Drives
37/// the `state` column in TTY output and the `state` field in the JSON
38/// envelope.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
40#[serde(rename_all = "lowercase")]
41pub enum RepoState {
42    /// Working tree clean; HEAD on a branch.
43    Clean,
44    /// Working tree has staged, unstaged, or untracked changes.
45    Dirty,
46    /// HEAD does not point at a branch.
47    Detached,
48    /// Repository initialized but no commits exist yet.
49    Unborn,
50    /// Bare repository — no working tree.
51    Bare,
52    /// Registered path no longer resolves to an accessible git repository.
53    Missing,
54}
55
56/// Per-repository status snapshot produced by [`inspect`]. Field order is the
57/// documented JSON serialization order.
58#[derive(Debug, Clone, Serialize)]
59pub struct RepoStatus {
60    pub name: String,
61    pub path: PathBuf,
62    pub branch: Option<String>,
63    pub upstream: Option<String>,
64    pub ahead: u32,
65    pub behind: u32,
66    pub dirty: bool,
67    pub staged: u32,
68    pub unstaged: u32,
69    pub untracked: u32,
70    pub state: RepoState,
71    pub error: Option<String>,
72    /// Short SHA exposed for the CLI's detached-HEAD `warn!` line. Skipped
73    /// from JSON output: the agent contract is `branch: null` + `state:
74    /// detached`, the SHA is a diagnostic for humans.
75    #[serde(skip)]
76    pub detached_sha: Option<String>,
77}
78
79impl RepoStatus {
80    fn missing(name: &str, path: &Path, error: String) -> Self {
81        Self::stub(name, path, RepoState::Missing, Some(error))
82    }
83
84    fn bare(name: &str, path: &Path) -> Self {
85        Self::stub(name, path, RepoState::Bare, Some("bare repository".into()))
86    }
87
88    fn stub(name: &str, path: &Path, state: RepoState, error: Option<String>) -> Self {
89        Self {
90            name: name.to_string(),
91            path: path.to_path_buf(),
92            branch: None,
93            upstream: None,
94            ahead: 0,
95            behind: 0,
96            dirty: false,
97            staged: 0,
98            unstaged: 0,
99            untracked: 0,
100            state,
101            error,
102            detached_sha: None,
103        }
104    }
105}
106
107/// Inspect a single registered repository and return its `RepoStatus`.
108///
109/// This function does not propagate errors via `Result`. Per the git-status
110/// design, the failure surface for an individual repo is per-row — a missing
111/// path, a broken `.git` directory, or a failed fetch all become a populated
112/// `error` field on the returned status, never an aborted batch.
113///
114/// When `fetch == true` and the repo is in `Clean`/`Dirty` state with a
115/// resolvable upstream, `git2::Remote::fetch` is invoked against the upstream
116/// remote of the current branch before `ahead`/`behind` are computed. Fetch
117/// failures populate `error` and leave ahead/behind reflecting the pre-fetch
118/// state.
119#[must_use]
120pub fn inspect(name: &str, path: &Path, fetch: bool) -> RepoStatus {
121    // Canonicalize so the row's `path` matches what's stored in the registry
122    // post-validation. If the path no longer exists or isn't accessible,
123    // surface as missing without a stack trace.
124    let canonical = match crate::path::canonicalize(path) {
125        Ok(p) => p,
126        Err(e) => {
127            return RepoStatus::missing(name, path, format!("{e}"));
128        }
129    };
130
131    let repo = match git2::Repository::open(&canonical) {
132        Ok(r) => r,
133        Err(e) => {
134            return RepoStatus::missing(name, &canonical, format!("{e}"));
135        }
136    };
137
138    if repo.is_bare() {
139        return RepoStatus::bare(name, &canonical);
140    }
141
142    // HEAD state: unborn (no commit), detached (HEAD points at a commit, no
143    // branch shorthand), or on a branch.
144    let head = repo.head();
145    let head_err = match head {
146        Ok(h) => Ok(h),
147        Err(e) if e.code() == git2::ErrorCode::UnbornBranch => Err(HeadFlavor::Unborn),
148        Err(e) => {
149            return RepoStatus::missing(name, &canonical, format!("{e}"));
150        }
151    };
152
153    let mut status = RepoStatus {
154        name: name.to_string(),
155        path: canonical,
156        branch: None,
157        upstream: None,
158        ahead: 0,
159        behind: 0,
160        dirty: false,
161        staged: 0,
162        unstaged: 0,
163        untracked: 0,
164        state: RepoState::Clean,
165        error: None,
166        detached_sha: None,
167    };
168
169    let (staged, unstaged, untracked) = count_statuses(&repo);
170    status.staged = staged;
171    status.unstaged = unstaged;
172    status.untracked = untracked;
173    status.dirty = staged + unstaged + untracked > 0;
174
175    match head_err {
176        Err(HeadFlavor::Unborn) => {
177            status.state = RepoState::Unborn;
178            return status;
179        }
180        Ok(head) => {
181            if head.is_branch() {
182                let branch_name = head.shorthand().ok().map(ToString::to_string);
183                status.branch.clone_from(&branch_name);
184                status.state = if status.dirty {
185                    RepoState::Dirty
186                } else {
187                    RepoState::Clean
188                };
189
190                if let Some(branch) = branch_name.as_deref() {
191                    if let Some(upstream_ref) = upstream_full_ref(&repo, branch) {
192                        status.upstream = upstream_short(&repo, &upstream_ref);
193
194                        if fetch {
195                            if let Err(fetch_err) = run_fetch(&repo, branch) {
196                                status.error = Some(fetch_err);
197                            }
198                        }
199
200                        if let Some((ahead, behind)) =
201                            compute_ahead_behind(&repo, &head, &upstream_ref)
202                        {
203                            status.ahead = u32::try_from(ahead).unwrap_or(u32::MAX);
204                            status.behind = u32::try_from(behind).unwrap_or(u32::MAX);
205                        }
206                    }
207                }
208            } else {
209                // Detached HEAD.
210                status.state = RepoState::Detached;
211                if let Ok(commit) = head.peel_to_commit() {
212                    let oid = commit.id();
213                    status.detached_sha = Some(short_oid(&oid));
214                }
215            }
216        }
217    }
218
219    status
220}
221
222enum HeadFlavor {
223    Unborn,
224}
225
226/// Walk `git2::Statuses` and count entries in three categories:
227/// staged (index ↔ HEAD diff), unstaged (worktree ↔ index diff), untracked.
228/// `.gitignored` entries are excluded.
229fn count_statuses(repo: &git2::Repository) -> (u32, u32, u32) {
230    let mut opts = git2::StatusOptions::new();
231    opts.include_untracked(true)
232        .include_ignored(false)
233        .exclude_submodules(false)
234        .recurse_untracked_dirs(true);
235    let Ok(statuses) = repo.statuses(Some(&mut opts)) else {
236        return (0, 0, 0);
237    };
238    let mut staged = 0u32;
239    let mut unstaged = 0u32;
240    let mut untracked = 0u32;
241    for entry in statuses.iter() {
242        let (s, u, t) = classify(entry.status());
243        if s {
244            staged = staged.saturating_add(1);
245        }
246        if u {
247            unstaged = unstaged.saturating_add(1);
248        }
249        if t {
250            untracked = untracked.saturating_add(1);
251        }
252    }
253    (staged, unstaged, untracked)
254}
255
256/// Map a `git2::Status` bitflag to `(staged, unstaged, untracked)` booleans.
257/// A single entry can be staged-and-unstaged simultaneously (e.g. modified
258/// after being staged); both bits are reported.
259const fn classify(status: git2::Status) -> (bool, bool, bool) {
260    let staged = status.intersects(git2::Status::from_bits_truncate(
261        git2::Status::INDEX_NEW.bits()
262            | git2::Status::INDEX_MODIFIED.bits()
263            | git2::Status::INDEX_DELETED.bits()
264            | git2::Status::INDEX_RENAMED.bits()
265            | git2::Status::INDEX_TYPECHANGE.bits(),
266    ));
267    let unstaged = status.intersects(git2::Status::from_bits_truncate(
268        git2::Status::WT_MODIFIED.bits()
269            | git2::Status::WT_DELETED.bits()
270            | git2::Status::WT_RENAMED.bits()
271            | git2::Status::WT_TYPECHANGE.bits(),
272    ));
273    let untracked = status.contains(git2::Status::WT_NEW) && !staged;
274    (staged, unstaged, untracked)
275}
276
277fn upstream_full_ref(repo: &git2::Repository, branch: &str) -> Option<String> {
278    let local_ref = format!("refs/heads/{branch}");
279    let upstream_buf = repo.branch_upstream_name(&local_ref).ok()?;
280    upstream_buf.as_str().ok().map(ToString::to_string)
281}
282
283fn upstream_short(repo: &git2::Repository, full_ref: &str) -> Option<String> {
284    let reference = repo.find_reference(full_ref).ok()?;
285    reference.shorthand().ok().map(ToString::to_string)
286}
287
288fn compute_ahead_behind(
289    repo: &git2::Repository,
290    head: &git2::Reference<'_>,
291    upstream_full_ref: &str,
292) -> Option<(usize, usize)> {
293    let local_oid = head.target()?;
294    let upstream_ref = repo.find_reference(upstream_full_ref).ok()?;
295    let upstream_oid = upstream_ref.target()?;
296    repo.graph_ahead_behind(local_oid, upstream_oid).ok()
297}
298
299fn run_fetch(repo: &git2::Repository, branch: &str) -> Result<(), String> {
300    // Resolve the remote name from `branch.<name>.remote`. Fall back to
301    // "origin" if that fails — matches `git fetch` behavior.
302    let config = repo.config().map_err(|e| e.message().to_string())?;
303    let remote_name = config
304        .get_string(&format!("branch.{branch}.remote"))
305        .unwrap_or_else(|_| "origin".to_string());
306    let mut remote = repo
307        .find_remote(&remote_name)
308        .map_err(|e| e.message().to_string())?;
309
310    let mut callbacks = git2::RemoteCallbacks::new();
311    // libgit2 invokes this callback repeatedly with different `allowed_types`
312    // as it tries successive auth methods. We track each branch with a flag so
313    // a single fetch doesn't loop indefinitely against the same failing method.
314    let mut tried_ssh_agent = false;
315    let mut tried_cred_helper = false;
316    let mut tried_default = false;
317    callbacks.credentials(move |url, username_from_url, allowed_types| {
318        if allowed_types.contains(git2::CredentialType::SSH_KEY) && !tried_ssh_agent {
319            tried_ssh_agent = true;
320            let user = username_from_url.unwrap_or("git");
321            return git2::Cred::ssh_key_from_agent(user);
322        }
323        if allowed_types.contains(git2::CredentialType::USER_PASS_PLAINTEXT) && !tried_cred_helper {
324            tried_cred_helper = true;
325            // Open default git config (~/.gitconfig + system) so the user's
326            // credential.helper setting (Keychain, libsecret, manager-core,
327            // etc.) is honored — matches what `git fetch` does on the shell.
328            let cfg = git2::Config::open_default()?;
329            return git2::Cred::credential_helper(&cfg, url, username_from_url);
330        }
331        if allowed_types.contains(git2::CredentialType::DEFAULT) && !tried_default {
332            tried_default = true;
333            return git2::Cred::default();
334        }
335        Err(git2::Error::from_str(
336            "no usable credential available (ssh-agent / credential helper exhausted)",
337        ))
338    });
339
340    let mut fo = git2::FetchOptions::new();
341    fo.remote_callbacks(callbacks);
342    remote
343        .fetch(&[branch], Some(&mut fo), None)
344        .map_err(|e| e.message().to_string())?;
345    Ok(())
346}
347
348fn short_oid(oid: &git2::Oid) -> String {
349    let s = oid.to_string();
350    s.chars().take(7).collect()
351}
352
353#[cfg(test)]
354mod tests {
355    #![allow(clippy::unwrap_used)]
356    use super::*;
357    use tempfile::TempDir;
358
359    #[test]
360    fn rejects_nonexistent_path() {
361        let tmp = TempDir::new().unwrap();
362        let err = validate_git_repo(&tmp.path().join("nope")).unwrap_err();
363        assert!(matches!(err, RepographError::NotFound { kind: "path", .. }));
364    }
365
366    #[test]
367    fn rejects_non_git_directory() {
368        let tmp = TempDir::new().unwrap();
369        let err = validate_git_repo(tmp.path()).unwrap_err();
370        assert!(matches!(err, RepographError::GitOpen { .. }));
371    }
372
373    #[test]
374    fn accepts_real_git_repo_returns_canonical() {
375        let tmp = TempDir::new().unwrap();
376        let repo_path = tmp.path().join("r");
377        std::fs::create_dir_all(&repo_path).unwrap();
378        git2::Repository::init(&repo_path).unwrap();
379
380        let resolved = validate_git_repo(&repo_path).unwrap();
381        assert_eq!(resolved, crate::path::canonicalize(&repo_path).unwrap());
382    }
383
384    // ─── inspect() unit tests ──────────────────────────────────────────────
385
386    /// Init a repo with one empty commit. Returns the repo dir.
387    fn init_with_commit(dir: &Path) {
388        let repo = git2::Repository::init(dir).unwrap();
389        let sig = git2::Signature::now("Test", "test@example.com").unwrap();
390        let tree_id = {
391            let mut index = repo.index().unwrap();
392            index.write_tree().unwrap()
393        };
394        let tree = repo.find_tree(tree_id).unwrap();
395        repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[])
396            .unwrap();
397    }
398
399    #[test]
400    fn inspect_clean_repo_no_upstream_is_clean() {
401        let tmp = TempDir::new().unwrap();
402        let dir = tmp.path().join("r");
403        std::fs::create_dir_all(&dir).unwrap();
404        init_with_commit(&dir);
405        let s = inspect("r", &dir, false);
406        assert_eq!(s.state, RepoState::Clean);
407        assert!(s.error.is_none());
408        assert!(s.branch.is_some());
409        assert!(s.upstream.is_none());
410        assert!(!s.dirty);
411    }
412
413    #[test]
414    fn inspect_dirty_repo_reports_dirty_with_unstaged() {
415        let tmp = TempDir::new().unwrap();
416        let dir = tmp.path().join("r");
417        std::fs::create_dir_all(&dir).unwrap();
418        init_with_commit(&dir);
419        // Add a tracked file via second commit, then modify it.
420        let repo = git2::Repository::open(&dir).unwrap();
421        let file = dir.join("tracked.txt");
422        std::fs::write(&file, "hello\n").unwrap();
423        {
424            let mut index = repo.index().unwrap();
425            index
426                .add_all(["tracked.txt"], git2::IndexAddOption::DEFAULT, None)
427                .unwrap();
428            index.write().unwrap();
429            let tree_id = index.write_tree().unwrap();
430            let sig = git2::Signature::now("T", "t@e").unwrap();
431            let tree = repo.find_tree(tree_id).unwrap();
432            let parent = repo.head().unwrap().peel_to_commit().unwrap();
433            repo.commit(Some("HEAD"), &sig, &sig, "track", &tree, &[&parent])
434                .unwrap();
435        }
436        drop(repo);
437        std::fs::write(&file, "modified\n").unwrap();
438
439        let s = inspect("r", &dir, false);
440        assert_eq!(s.state, RepoState::Dirty);
441        assert!(s.dirty);
442        assert!(s.unstaged >= 1);
443    }
444
445    #[test]
446    fn inspect_untracked_file_alone_reports_dirty() {
447        let tmp = TempDir::new().unwrap();
448        let dir = tmp.path().join("r");
449        std::fs::create_dir_all(&dir).unwrap();
450        init_with_commit(&dir);
451        std::fs::write(dir.join("new.txt"), "x").unwrap();
452
453        let s = inspect("r", &dir, false);
454        assert_eq!(s.state, RepoState::Dirty);
455        assert_eq!(s.untracked, 1);
456    }
457
458    #[test]
459    fn inspect_staged_only_reports_dirty() {
460        let tmp = TempDir::new().unwrap();
461        let dir = tmp.path().join("r");
462        std::fs::create_dir_all(&dir).unwrap();
463        init_with_commit(&dir);
464        let repo = git2::Repository::open(&dir).unwrap();
465        std::fs::write(dir.join("staged.txt"), "x").unwrap();
466        {
467            let mut index = repo.index().unwrap();
468            index
469                .add_all(["staged.txt"], git2::IndexAddOption::DEFAULT, None)
470                .unwrap();
471            index.write().unwrap();
472        }
473        drop(repo);
474
475        let s = inspect("r", &dir, false);
476        assert_eq!(s.staged, 1);
477        assert_eq!(s.untracked, 0);
478        assert_eq!(s.state, RepoState::Dirty);
479    }
480
481    #[test]
482    fn inspect_detached_head_reports_detached() {
483        let tmp = TempDir::new().unwrap();
484        let dir = tmp.path().join("r");
485        std::fs::create_dir_all(&dir).unwrap();
486        init_with_commit(&dir);
487        let repo = git2::Repository::open(&dir).unwrap();
488        let head_id = {
489            let head = repo.head().unwrap().peel_to_commit().unwrap();
490            head.id()
491        };
492        repo.set_head_detached(head_id).unwrap();
493        drop(repo);
494
495        let s = inspect("r", &dir, false);
496        assert_eq!(s.state, RepoState::Detached);
497        assert!(s.branch.is_none());
498        assert!(s.detached_sha.as_deref().map_or(0, str::len) == 7);
499    }
500
501    #[test]
502    fn inspect_unborn_repo_reports_unborn() {
503        let tmp = TempDir::new().unwrap();
504        let dir = tmp.path().join("r");
505        std::fs::create_dir_all(&dir).unwrap();
506        git2::Repository::init(&dir).unwrap();
507
508        let s = inspect("r", &dir, false);
509        assert_eq!(s.state, RepoState::Unborn);
510        assert!(s.branch.is_none());
511        assert!(s.error.is_none());
512    }
513
514    #[test]
515    fn inspect_bare_repo_reports_bare() {
516        let tmp = TempDir::new().unwrap();
517        let dir = tmp.path().join("r.git");
518        std::fs::create_dir_all(&dir).unwrap();
519        git2::Repository::init_bare(&dir).unwrap();
520
521        let s = inspect("r", &dir, false);
522        assert_eq!(s.state, RepoState::Bare);
523        assert!(s.error.is_some());
524    }
525
526    #[test]
527    fn inspect_missing_path_reports_missing() {
528        let tmp = TempDir::new().unwrap();
529        let s = inspect("r", &tmp.path().join("gone"), false);
530        assert_eq!(s.state, RepoState::Missing);
531        assert!(s.error.is_some());
532    }
533
534    #[test]
535    fn inspect_directory_without_git_dir_reports_missing() {
536        let tmp = TempDir::new().unwrap();
537        let dir = tmp.path().join("r");
538        std::fs::create_dir_all(&dir).unwrap();
539        let s = inspect("r", &dir, false);
540        assert_eq!(s.state, RepoState::Missing);
541        assert!(s.error.is_some());
542    }
543
544    #[test]
545    fn classify_index_new_is_staged() {
546        let (staged, unstaged, untracked) = classify(git2::Status::INDEX_NEW);
547        assert!(staged);
548        assert!(!unstaged);
549        assert!(!untracked);
550    }
551
552    #[test]
553    fn classify_worktree_new_is_untracked() {
554        let (staged, unstaged, untracked) = classify(git2::Status::WT_NEW);
555        assert!(!staged);
556        assert!(!unstaged);
557        assert!(untracked);
558    }
559
560    #[test]
561    fn classify_index_new_plus_worktree_modified_is_staged_and_unstaged() {
562        let combined = git2::Status::INDEX_NEW | git2::Status::WT_MODIFIED;
563        let (staged, unstaged, untracked) = classify(combined);
564        assert!(staged);
565        assert!(unstaged);
566        assert!(!untracked);
567    }
568}