Skip to main content

wsx_core/git/
info.rs

1// Git info via CLI — branch, commits, modified files, ahead/behind
2
3use super::git_cmd;
4use crate::model::workspace::{CommitSummary, FetchFailReason, GitInfo};
5use std::path::Path;
6use std::process::Command;
7
8pub struct FetchOutcome {
9    pub success: bool,
10    pub reason: Option<FetchFailReason>,
11}
12
13pub fn get_git_info(worktree_path: &Path, _default_branch: &str) -> Option<GitInfo> {
14    // Single subprocess: captures branch, upstream, ahead/behind, and modified files.
15    // If status fails (e.g. corrupt index), do not overwrite existing UI state.
16    let (branch, remote_branch, ahead, behind, modified_files) = status_porcelain2(worktree_path)?;
17    let _ = branch; // branch confirmed valid; value unused for now
18    let recent_commits = recent_commits(worktree_path, 3);
19    Some(GitInfo {
20        recent_commits,
21        modified_files,
22        ahead,
23        behind,
24        remote_branch,
25    })
26}
27
28type StatusResult = (String, Option<String>, usize, usize, Vec<String>);
29
30/// Parse `git status --porcelain=2 --branch` output.
31/// Returns (branch, upstream, ahead, behind, modified_files) or None on failure.
32fn status_porcelain2(path: &Path) -> Option<StatusResult> {
33    let out = super::output_with_timeout(
34        git_read(path).args(["status", "--porcelain=2", "--branch"]),
35        std::time::Duration::from_secs(10),
36    )
37    .ok()?;
38    if !out.status.success() {
39        return None;
40    }
41    let text = String::from_utf8_lossy(&out.stdout);
42    let mut branch = String::new();
43    let mut upstream: Option<String> = None;
44    let mut ahead = 0usize;
45    let mut behind = 0usize;
46    let mut modified_files: Vec<String> = Vec::new();
47
48    for line in text.lines() {
49        if let Some(val) = line.strip_prefix("# branch.head ") {
50            branch = val.trim().to_string();
51        } else if let Some(val) = line.strip_prefix("# branch.upstream ") {
52            let u = val.trim().to_string();
53            if !u.is_empty() {
54                upstream = Some(u);
55            }
56        } else if let Some(val) = line.strip_prefix("# branch.ab ") {
57            // "+<ahead> -<behind>"
58            let mut parts = val.split_whitespace();
59            if let Some(a) = parts.next() {
60                ahead = a.trim_start_matches('+').parse().unwrap_or(0);
61            }
62            if let Some(b) = parts.next() {
63                behind = b.trim_start_matches('-').parse().unwrap_or(0);
64            }
65        } else if line.starts_with("1 ") || line.starts_with("2 ") || line.starts_with("u ") {
66            // Type "1": "1 XY ... path" — path is last whitespace token
67            // Type "2": "2 XY ... score path\toldpath" — tab separates new/old paths
68            let path_str = if line.starts_with("2 ") {
69                // Split off the tab-separated part first, take the new path
70                line.split('\t')
71                    .next()
72                    .and_then(|before_tab| before_tab.split_whitespace().last())
73            } else {
74                line.split_whitespace().last()
75            };
76            if let Some(path_part) = path_str {
77                if modified_files.len() < 10 {
78                    modified_files.push(path_part.to_string());
79                }
80            }
81        } else if line.starts_with("? ") {
82            // Untracked file
83            if let Some(path_part) = line.strip_prefix("? ") {
84                if modified_files.len() < 10 {
85                    modified_files.push(path_part.trim().to_string());
86                }
87            }
88        }
89    }
90
91    if branch.is_empty() || branch == "(detached)" {
92        // Confirm we're in a real worktree with a branch
93        if branch.is_empty() {
94            return None;
95        }
96    }
97
98    Some((branch, upstream, ahead, behind, modified_files))
99}
100
101/// Advisory cross-process lockfile for git fetch. Created with O_CREAT|O_EXCL.
102/// Returns the lock path if acquired, None if another process holds it (< 120s old).
103fn try_fetch_lock(path: &Path) -> Option<std::path::PathBuf> {
104    use std::hash::{Hash, Hasher};
105    let mut h = std::collections::hash_map::DefaultHasher::new();
106    path.hash(&mut h);
107    let hash = h.finish();
108    let lock_path = std::env::temp_dir().join(format!("wsx-fetch-{:x}.lock", hash));
109    // Check if existing lock is stale (> 120s) — crashed process protection
110    if let Ok(meta) = std::fs::metadata(&lock_path) {
111        let age = meta
112            .modified()
113            .ok()
114            .and_then(|t| t.elapsed().ok())
115            .map(|d| d.as_secs())
116            .unwrap_or(u64::MAX);
117        if age < 120 {
118            return None; // another process holds a fresh lock
119        }
120        let _ = std::fs::remove_file(&lock_path); // stale, clean up
121    }
122    // Try atomic create with O_CREAT|O_EXCL
123    use std::fs::OpenOptions;
124    use std::io::Write;
125    let mut opts = OpenOptions::new();
126    opts.write(true).create_new(true);
127    match opts.open(&lock_path) {
128        Ok(mut f) => {
129            let _ = write!(f, "{}", std::process::id());
130            Some(lock_path)
131        }
132        Err(_) => None, // lost the race
133    }
134}
135
136/// RAII guard that removes the lockfile on drop.
137struct FetchLockGuard(std::path::PathBuf);
138impl Drop for FetchLockGuard {
139    fn drop(&mut self) {
140        let _ = std::fs::remove_file(&self.0);
141    }
142}
143
144/// Run `git fetch` — uses `output_with_timeout` for process-group cleanup on timeout.
145/// Advisory cross-process lockfile prevents duplicate concurrent fetches from multiple instances.
146pub fn git_fetch(path: &Path) -> FetchOutcome {
147    let Some(lock_path) = try_fetch_lock(path) else {
148        // Another instance is handling this fetch; report success so backoff stays low.
149        return FetchOutcome {
150            success: true,
151            reason: None,
152        };
153    };
154    let _lock = FetchLockGuard(lock_path);
155    let result = super::output_with_timeout(
156        git_cmd(path).args(["fetch", "--no-tags", "--quiet"]),
157        std::time::Duration::from_secs(10),
158    );
159    match result {
160        Err(e) if e.kind() == std::io::ErrorKind::TimedOut => FetchOutcome {
161            success: false,
162            reason: Some(FetchFailReason::Timeout),
163        },
164        Err(_) => FetchOutcome {
165            success: false,
166            reason: Some(FetchFailReason::Network),
167        },
168        Ok(out) if out.status.success() => FetchOutcome {
169            success: true,
170            reason: None,
171        },
172        Ok(out) => {
173            let stderr = String::from_utf8_lossy(&out.stderr);
174            FetchOutcome {
175                success: false,
176                reason: Some(classify_fetch_error(&stderr)),
177            }
178        }
179    }
180}
181
182fn classify_fetch_error(stderr: &str) -> FetchFailReason {
183    let lower = stderr.to_lowercase();
184    if lower.contains("authentication failed")
185        || lower.contains("permission denied")
186        || lower.contains("could not read username")
187        || lower.contains("invalid username or password")
188        || lower.contains("repository not found")
189    {
190        FetchFailReason::Auth
191    } else {
192        FetchFailReason::Network
193    }
194}
195
196pub fn current_branch(path: &Path) -> Option<String> {
197    let out = super::output_with_timeout(
198        git_read(path).args(["branch", "--show-current"]),
199        std::time::Duration::from_secs(5),
200    )
201    .ok()?;
202    if !out.status.success() {
203        return None;
204    }
205    let branch = String::from_utf8_lossy(&out.stdout).trim().to_string();
206    if branch.is_empty() {
207        None
208    } else {
209        Some(branch)
210    }
211}
212
213fn recent_commits(path: &Path, n: usize) -> Vec<CommitSummary> {
214    let Ok(out) = super::output_with_timeout(
215        git_read(path).args(["log", "--oneline", &format!("-{}", n)]),
216        std::time::Duration::from_secs(5),
217    ) else {
218        return vec![];
219    };
220    if !out.status.success() {
221        return vec![];
222    }
223    String::from_utf8_lossy(&out.stdout)
224        .lines()
225        .filter_map(|line| {
226            let mut parts = line.splitn(2, ' ');
227            let hash = parts.next()?.to_string();
228            let message = parts.next().unwrap_or("").to_string();
229            Some(CommitSummary { hash, message })
230        })
231        .collect()
232}
233
234fn git_read(path: &Path) -> Command {
235    let mut cmd = git_cmd(path);
236    cmd.arg("--no-optional-locks");
237    cmd
238}
239
240#[cfg(test)]
241mod tests {
242    use super::{get_git_info, try_fetch_lock, FetchLockGuard};
243    use std::fs;
244    use std::path::{Path, PathBuf};
245    use std::process::Command;
246    use std::sync::atomic::{AtomicUsize, Ordering};
247    use std::time::{SystemTime, UNIX_EPOCH};
248
249    #[test]
250    fn fetch_lock_acquired_on_fresh_path() {
251        let path = PathBuf::from("/tmp/wsx_test_lock_fresh");
252        let result = try_fetch_lock(&path);
253        assert!(result.is_some(), "should acquire lock on a fresh path");
254        let lock_path = result.unwrap();
255        assert!(lock_path.exists(), "lockfile should exist after acquire");
256        let _guard = FetchLockGuard(lock_path.clone());
257        // guard drop removes file
258        drop(_guard);
259        assert!(!lock_path.exists(), "lockfile should be removed on drop");
260    }
261
262    #[test]
263    fn fetch_lock_fails_when_held() {
264        let path = PathBuf::from("/tmp/wsx_test_lock_held");
265        let lock1 = try_fetch_lock(&path);
266        assert!(lock1.is_some(), "first acquire should succeed");
267        let lock2 = try_fetch_lock(&path);
268        assert!(
269            lock2.is_none(),
270            "second acquire should fail while first is held"
271        );
272        drop(lock1.map(FetchLockGuard));
273    }
274
275    #[test]
276    fn fetch_lock_different_paths_independent() {
277        let path_a = PathBuf::from("/tmp/wsx_test_lock_a");
278        let path_b = PathBuf::from("/tmp/wsx_test_lock_b");
279        let lock_a = try_fetch_lock(&path_a);
280        let lock_b = try_fetch_lock(&path_b);
281        assert!(lock_a.is_some(), "lock for path_a should succeed");
282        assert!(
283            lock_b.is_some(),
284            "lock for path_b should succeed independently"
285        );
286        drop(lock_a.map(FetchLockGuard));
287        drop(lock_b.map(FetchLockGuard));
288    }
289
290    static NEXT_TEMP_ID: AtomicUsize = AtomicUsize::new(0);
291
292    fn git(path: &Path, args: &[&str]) {
293        let status = Command::new("git")
294            .arg("-C")
295            .arg(path)
296            .args(args)
297            .status()
298            .expect("git command should run");
299        assert!(
300            status.success(),
301            "git command failed: git -C {:?} {:?}",
302            path,
303            args
304        );
305    }
306
307    fn init_temp_repo() -> PathBuf {
308        let mut path = std::env::temp_dir();
309        let suffix = SystemTime::now()
310            .duration_since(UNIX_EPOCH)
311            .expect("clock should be after unix epoch")
312            .as_nanos();
313        let id = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed);
314        path.push(format!(
315            "wsx-git-info-test-{}-{}-{}",
316            std::process::id(),
317            suffix,
318            id
319        ));
320        fs::create_dir_all(&path).expect("temp repo dir should be created");
321
322        git(&path, &["init", "-q"]);
323        git(&path, &["config", "user.email", "test@example.com"]);
324        git(&path, &["config", "user.name", "Test User"]);
325        fs::write(path.join("tracked.txt"), "first\n").expect("tracked file should be written");
326        git(&path, &["add", "tracked.txt"]);
327        git(&path, &["commit", "-m", "init", "-q"]);
328        path
329    }
330
331    #[test]
332    fn get_git_info_reports_dirty_file() {
333        let repo = init_temp_repo();
334        fs::write(repo.join("tracked.txt"), "changed\n").expect("tracked file should be updated");
335        let info = get_git_info(&repo, "main").expect("git info should be available");
336        assert!(
337            info.modified_files.iter().any(|f| f == "tracked.txt"),
338            "expected tracked.txt in modified files, got {:?}",
339            info.modified_files
340        );
341        let _ = fs::remove_dir_all(repo);
342    }
343
344    #[test]
345    fn get_git_info_returns_none_when_status_fails() {
346        let repo = init_temp_repo();
347        // Corrupt index so branch detection still works but status exits non-zero.
348        fs::write(repo.join(".git").join("index"), "broken").expect("index should be overwritten");
349
350        let info = get_git_info(&repo, "main");
351        assert!(
352            info.is_none(),
353            "expected None when status fails, got {:?}",
354            info
355        );
356        let _ = fs::remove_dir_all(repo);
357    }
358}