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}