Skip to main content

dejavu/
repo.rs

1//! Repo root detection and git-state metadata capture.
2//!
3//! All git subprocesses set `DEJAVU_DISABLED=1` so that if a shim is somehow
4//! reached anyway, the nested `dejavu run` passes straight through instead of
5//! re-entering the capture pipeline.
6
7use crate::env;
8use std::ffi::OsString;
9use std::path::{Path, PathBuf};
10use std::process::Command;
11
12/// How internal git metadata queries are spawned.
13///
14/// On the hot shim path this carries the *resolved real* git binary and the
15/// sanitized `PATH` (shim dir removed), so each internal query costs one
16/// process. The default (`git` on the ambient `PATH`) is for
17/// latency-insensitive callers — CLI commands, the session launcher — where,
18/// under global activation, the call may route through a shim and terminate
19/// via `DEJAVU_DISABLED=1` (correct, just three processes instead of one).
20#[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    /// An invoker that spawns `program` directly with `PATH=env_path`.
37    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    /// The repo root via `git rev-parse --show-toplevel`, falling back to `cwd`
55    /// (canonicalized) when not inside a git repo.
56    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    /// The current commit hash, if the repo has one.
74    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    /// A stable hash of the working-tree state: `sha256(git status
91    /// --porcelain=v1 -z)`. `None` when not a git repo / git absent.
92    /// Annotation only — never used to filter run comparability.
93    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
113/// Git-state metadata stored with a run. Annotation only (spec decision #3):
114/// it feeds the "across code changes" / "possible flaky" note, never the
115/// comparability match.
116pub struct GitState {
117    pub head: Option<String>,
118    pub worktree_hash: Option<String>,
119}
120
121/// `HEAD` + worktree hash computed on a background thread **while the real
122/// command runs**, joined only when the run is stored. On big worktrees `git
123/// status` costs hundreds of ms; overlapping it with the command hides that
124/// cost entirely whenever the command outlasts it.
125///
126/// The snapshot starts at command start instead of after completion — for the
127/// read-only commands Dejavu optimizes the two are identical, and for test
128/// runs that write ignored files, start-state is the more faithful fingerprint
129/// of what produced the output. Any thread failure degrades to `None`s.
130pub enum GitStatePrefetch {
131    Spawned(std::thread::JoinHandle<GitState>),
132    /// Thread spawn failed — compute inline at join time.
133    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
163/// The repo root using the default (ambient `PATH`) invoker — for
164/// latency-insensitive callers like `dejavu start` and the CLI commands.
165pub 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        // The worktree hash moves when the tree changes.
206        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}