1use std::path::{Path, PathBuf};
4
5use serde::Serialize;
6
7use crate::error::RepographError;
8
9pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
40#[serde(rename_all = "lowercase")]
41pub enum RepoState {
42 Clean,
44 Dirty,
46 Detached,
48 Unborn,
50 Bare,
52 Missing,
54}
55
56#[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 #[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#[must_use]
120pub fn inspect(name: &str, path: &Path, fetch: bool) -> RepoStatus {
121 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 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 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
226fn 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
256const 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 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 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 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 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 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}