1use crate::env;
8use std::ffi::OsString;
9use std::path::{Path, PathBuf};
10use std::process::Command;
11
12#[derive(Debug, Clone)]
21pub struct GitInvoker {
22 program: PathBuf,
23 env_path: Option<OsString>,
24}
25
26impl Default for GitInvoker {
27 fn default() -> Self {
28 GitInvoker {
29 program: PathBuf::from("git"),
30 env_path: None,
31 }
32 }
33}
34
35impl GitInvoker {
36 pub fn resolved(program: PathBuf, env_path: OsString) -> GitInvoker {
38 GitInvoker {
39 program,
40 env_path: Some(env_path),
41 }
42 }
43
44 fn cmd(&self, dir: &Path) -> Command {
45 let mut cmd = Command::new(&self.program);
46 cmd.arg("-C").arg(dir);
47 if let Some(p) = &self.env_path {
48 cmd.env("PATH", p);
49 }
50 cmd.env(env::DISABLED, "1");
51 cmd
52 }
53
54 pub fn detect_repo_root(&self, cwd: &Path) -> PathBuf {
57 if let Ok(out) = self
58 .cmd(cwd)
59 .args(["rev-parse", "--show-toplevel"])
60 .output()
61 {
62 if out.status.success() {
63 let text = String::from_utf8_lossy(&out.stdout);
64 let trimmed = text.trim();
65 if !trimmed.is_empty() {
66 return PathBuf::from(trimmed);
67 }
68 }
69 }
70 std::fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf())
71 }
72
73 pub fn head(&self, repo_root: &Path) -> Option<String> {
75 let out = self
76 .cmd(repo_root)
77 .args(["rev-parse", "HEAD"])
78 .output()
79 .ok()?;
80 if out.status.success() {
81 let text = String::from_utf8_lossy(&out.stdout);
82 let trimmed = text.trim();
83 if !trimmed.is_empty() {
84 return Some(trimmed.to_string());
85 }
86 }
87 None
88 }
89
90 pub fn worktree_hash(&self, repo_root: &Path) -> Option<String> {
94 let out = self
95 .cmd(repo_root)
96 .args(["status", "--porcelain=v1", "-z"])
97 .output()
98 .ok()?;
99 if out.status.success() {
100 return Some(crate::util::sha256_hex(&out.stdout));
101 }
102 None
103 }
104
105 fn state(&self, repo_root: &Path) -> GitState {
106 GitState {
107 head: self.head(repo_root),
108 worktree_hash: self.worktree_hash(repo_root),
109 }
110 }
111}
112
113pub struct GitState {
117 pub head: Option<String>,
118 pub worktree_hash: Option<String>,
119}
120
121pub enum GitStatePrefetch {
131 Spawned(std::thread::JoinHandle<GitState>),
132 Inline {
134 invoker: GitInvoker,
135 repo_root: PathBuf,
136 },
137}
138
139impl GitStatePrefetch {
140 pub fn spawn(invoker: GitInvoker, repo_root: PathBuf) -> GitStatePrefetch {
141 let thread_invoker = invoker.clone();
142 let thread_root = repo_root.clone();
143 match std::thread::Builder::new()
144 .name("dejavu-git-state".to_string())
145 .spawn(move || thread_invoker.state(&thread_root))
146 {
147 Ok(handle) => GitStatePrefetch::Spawned(handle),
148 Err(_) => GitStatePrefetch::Inline { invoker, repo_root },
149 }
150 }
151
152 pub fn join(self) -> GitState {
153 match self {
154 GitStatePrefetch::Spawned(handle) => handle.join().unwrap_or(GitState {
155 head: None,
156 worktree_hash: None,
157 }),
158 GitStatePrefetch::Inline { invoker, repo_root } => invoker.state(&repo_root),
159 }
160 }
161}
162
163pub fn detect_repo_root(cwd: &Path) -> PathBuf {
166 GitInvoker::default().detect_repo_root(cwd)
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172
173 #[test]
174 fn prefetch_joins_to_nones_outside_a_repo() {
175 let tmp = tempfile::tempdir().unwrap();
176 let state = GitStatePrefetch::spawn(GitInvoker::default(), tmp.path().to_path_buf()).join();
177 assert!(state.head.is_none());
178 assert!(state.worktree_hash.is_none());
179 }
180
181 #[test]
182 fn prefetch_captures_state_in_a_repo() {
183 let tmp = tempfile::tempdir().unwrap();
184 let run = |args: &[&str]| {
185 std::process::Command::new("git")
186 .arg("-C")
187 .arg(tmp.path())
188 .args(args)
189 .env("GIT_CONFIG_GLOBAL", "/dev/null")
190 .env("GIT_CONFIG_SYSTEM", "/dev/null")
191 .output()
192 .unwrap()
193 };
194 run(&["init", "-q", "."]);
195 run(&["config", "user.email", "t@t"]);
196 run(&["config", "user.name", "t"]);
197 std::fs::write(tmp.path().join("f.txt"), "x").unwrap();
198 run(&["add", "-A"]);
199 run(&["-c", "commit.gpgsign=false", "commit", "-qm", "init"]);
200
201 let state = GitStatePrefetch::spawn(GitInvoker::default(), tmp.path().to_path_buf()).join();
202 assert!(state.head.is_some(), "HEAD should exist after a commit");
203 assert!(state.worktree_hash.is_some());
204
205 std::fs::write(tmp.path().join("g.txt"), "y").unwrap();
207 let dirty = GitStatePrefetch::spawn(GitInvoker::default(), tmp.path().to_path_buf()).join();
208 assert_ne!(state.worktree_hash, dirty.worktree_hash);
209 }
210}