Skip to main content

trail/workers/
git.rs

1//! Git status worker.
2//!
3//! Computes repo indicator, branch name, and optional per-file status.
4//! Results are cached per directory and invalidated on `FsChanged` events.
5//!
6//! Spawns a Tokio task that calls `gix` synchronously inside `spawn_blocking`
7//! so the UI thread is never blocked. Sends a `WorkerMsg::Git` back over the
8//! provided sender when done.
9//!
10//! # Feature dependencies
11//!
12//! The `gix` dependency must include the `status` feature (see `Cargo.toml`)
13//! for `Repository::is_dirty()` to compile.
14
15use std::collections::HashMap;
16use std::path::{Path, PathBuf};
17use std::sync::{Arc, Mutex};
18
19use tokio::sync::mpsc::Sender;
20use tokio::task;
21
22use crate::app::state::{GitDirState, GitFileStatus};
23use crate::workers::WorkerMsg;
24
25// ── Cache ─────────────────────────────────────────────────────────────────────
26
27/// A cached git result for a directory.
28#[derive(Debug, Clone)]
29pub struct CachedGit {
30    dir_state: Option<GitDirState>,
31    file_statuses: Vec<(String, GitFileStatus)>,
32}
33
34/// Shared git cache keyed by canonical directory path.
35///
36/// Invalidated when a `WorkerMsg::FsChanged` arrives for the same path.
37/// `Arc<Mutex<…>>` lets the main thread own the cache while worker tasks
38/// populate it.
39pub type GitCache = Arc<Mutex<HashMap<PathBuf, CachedGit>>>;
40
41/// Creates a new, empty git cache.
42pub fn new_cache() -> GitCache {
43    Arc::new(Mutex::new(HashMap::new()))
44}
45
46/// Removes the cached git result for `path`, forcing a recompute on the next
47/// `spawn_git_status` call.
48///
49/// Called when `WorkerMsg::FsChanged` arrives for the watched directory.
50pub fn invalidate(cache: &GitCache, path: &Path) {
51    if let Ok(mut c) = cache.lock() {
52        c.remove(path);
53        tracing::debug!(?path, "git cache invalidated");
54    }
55}
56
57// ── spawn_git_status ──────────────────────────────────────────────────────────
58
59/// Spawns a Tokio task that computes git status for `path` and sends the
60/// result as a `WorkerMsg::Git` over `tx`.
61///
62/// If the result is already in `cache` it is sent immediately without
63/// spawning a new blocking task.
64///
65/// The task is fire-and-forget; the caller does not await it. If `tx` is
66/// closed (the UI thread has exited), the result is silently dropped.
67pub fn spawn_git_status(path: PathBuf, tx: Sender<WorkerMsg>, cache: GitCache) {
68    // Check the cache before spawning a blocking task.
69    if let Ok(c) = cache.lock() {
70        if let Some(cached) = c.get(&path) {
71            let msg = WorkerMsg::Git {
72                path: path.clone(),
73                state: cached.dir_state.clone(),
74                file_statuses: cached.file_statuses.clone(),
75            };
76            let tx2 = tx.clone();
77            tokio::spawn(async move {
78                // Ignore send error: UI thread may have exited.
79                let _ = tx2.send(msg).await;
80            });
81            return;
82        }
83    }
84
85    // Not cached — compute in a blocking thread.
86    tokio::spawn(async move {
87        let path2 = path.clone();
88        let result = task::spawn_blocking(move || compute_git_status(&path2)).await;
89
90        let (dir_state, file_statuses) = match result {
91            Ok(r) => r,
92            Err(e) => {
93                tracing::debug!("git worker task panicked: {e}");
94                return;
95            }
96        };
97
98        // Populate the cache.
99        if let Ok(mut c) = cache.lock() {
100            c.insert(
101                path.clone(),
102                CachedGit {
103                    dir_state: dir_state.clone(),
104                    file_statuses: file_statuses.clone(),
105                },
106            );
107        }
108
109        let _ = tx
110            .send(WorkerMsg::Git {
111                path,
112                state: dir_state,
113                file_statuses,
114            })
115            .await;
116    });
117}
118
119// ── Computation (runs inside spawn_blocking) ──────────────────────────────────
120
121/// Computes the git status for `path` synchronously.
122///
123/// Returns `(None, [])` when `path` is not inside a git repository, or when
124/// the repository cannot be opened (e.g. permissions, corrupt objects).
125fn compute_git_status(path: &Path) -> (Option<GitDirState>, Vec<(String, GitFileStatus)>) {
126    // Walk up from `path` looking for a `.git` directory.
127    let repo = match gix::discover(path) {
128        Ok(r) => r,
129        Err(_) => {
130            tracing::debug!(?path, "no git repository found");
131            return (None, vec![]);
132        }
133    };
134
135    // Resolve HEAD → branch name.
136    let branch = match repo.head_name() {
137        Ok(Some(name)) => name.shorten().to_string(),
138        Ok(None) => "HEAD".to_owned(), // detached HEAD
139        Err(e) => {
140            tracing::debug!(?path, "failed to read HEAD: {e}");
141            "HEAD".to_owned()
142        }
143    };
144
145    // Determine whether the working tree has uncommitted changes.
146    let is_dirty = match repo.is_dirty() {
147        Ok(d) => d,
148        Err(e) => {
149            tracing::debug!(?path, "failed to check dirty state: {e}");
150            false
151        }
152    };
153
154    let dir_state = Some(GitDirState { branch, is_dirty });
155
156    // Compute per-file statuses for entries in `path`.
157    let file_statuses = compute_file_statuses(&repo, path);
158
159    (dir_state, file_statuses)
160}
161
162/// Computes per-file git statuses for direct children of `dir`.
163///
164/// Only files immediately inside `dir` (non-recursive) are included, since
165/// the nav panel only shows the current directory level.
166///
167/// This implementation uses a lightweight heuristic: it checks the index for
168/// tracked files and marks files not found on disk as `Deleted`. Untracked
169/// files remain unlisted (callers can interpret absent = untracked).
170fn compute_file_statuses(repo: &gix::Repository, dir: &Path) -> Vec<(String, GitFileStatus)> {
171    let workdir = match repo.workdir() {
172        Some(p) => p.to_owned(),
173        None => return vec![], // bare repo
174    };
175
176    let index = match repo.index_or_empty() {
177        Ok(i) => i,
178        Err(e) => {
179            tracing::debug!("failed to read git index: {e}");
180            return vec![];
181        }
182    };
183
184    let mut statuses: Vec<(String, GitFileStatus)> = Vec::new();
185
186    for entry in index.entries() {
187        // entry.path() returns the repo-relative path as raw bytes.
188        let rel_path_bytes = entry.path(&index);
189        let rel_path_str = match std::str::from_utf8(rel_path_bytes) {
190            Ok(s) => s,
191            Err(_) => continue,
192        };
193        let abs_path = workdir.join(rel_path_str);
194
195        // Only include direct children of `dir`.
196        let parent = match abs_path.parent() {
197            Some(p) => p,
198            None => continue,
199        };
200        if parent != dir {
201            continue;
202        }
203
204        let file_name = match abs_path.file_name().and_then(|n| n.to_str()) {
205            Some(n) => n.to_owned(),
206            None => continue,
207        };
208
209        // Lightweight status: check whether the file exists on disk.
210        let status = if abs_path.exists() {
211            // For Phase 4 a shallow existence check is sufficient; Phase 7
212            // can expose a config flag to enable full mtime/content comparison.
213            GitFileStatus::Clean
214        } else {
215            GitFileStatus::Deleted
216        };
217
218        statuses.push((file_name, status));
219    }
220
221    statuses
222}