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::{
5    CommitSummary, FetchFailReason, GitInfo, SubmoduleCommitState, SubmoduleInfo, SubtreeInfo,
6};
7use std::path::{Path, PathBuf};
8use std::process::Command;
9
10pub struct FetchOutcome {
11    pub success: bool,
12    pub reason: Option<FetchFailReason>,
13}
14
15pub fn get_git_info(
16    worktree_path: &Path,
17    _default_branch: &str,
18    configured_subtrees: &[PathBuf],
19) -> Option<GitInfo> {
20    // ^ [[Worktree Model]] Git owns submodule gitlinks; project config only
21    // declares otherwise-indistinguishable subtree roots.
22    let status = status_porcelain2(worktree_path)?;
23    let mut submodules = submodule_status(worktree_path);
24    let mut ordinary_files = Vec::new();
25    for entry in status.entries {
26        let Some(token) = entry.submodule else {
27            ordinary_files.push(entry.path);
28            continue;
29        };
30        if let Some(submodules) = &mut submodules {
31            let info = submodules
32                .iter_mut()
33                .find(|submodule| submodule.path == entry.path);
34            let submodule = match info {
35                Some(submodule) => submodule,
36                None => {
37                    submodules.push(SubmoduleInfo {
38                        path: entry.path.clone(),
39                        commit_state: SubmoduleCommitState::InSync,
40                        modified_content: false,
41                        untracked_content: false,
42                    });
43                    submodules.last_mut().expect("just inserted submodule")
44                }
45            };
46            if entry.unmerged {
47                submodule.commit_state = SubmoduleCommitState::Conflict;
48            } else if token.as_bytes().get(1) == Some(&b'C') {
49                submodule.commit_state = SubmoduleCommitState::CommitChanged;
50            }
51            submodule.modified_content |= token.as_bytes().get(2) == Some(&b'M');
52            submodule.untracked_content |= token.as_bytes().get(3) == Some(&b'U');
53        }
54    }
55
56    let mut subtrees = configured_subtrees
57        .iter()
58        .map(|path| SubtreeInfo {
59            path: path.to_string_lossy().into_owned(),
60            modified_files: Vec::new(),
61        })
62        .collect::<Vec<_>>();
63    let mut modified_files = Vec::new();
64    for file in ordinary_files {
65        let file_path = Path::new(&file);
66        if let Some(subtree) = subtrees
67            .iter_mut()
68            .find(|subtree| file_path.starts_with(Path::new(&subtree.path)))
69        {
70            subtree.modified_files.push(file);
71        } else {
72            modified_files.push(file);
73        }
74    }
75
76    Some(GitInfo {
77        recent_commits: recent_commits(worktree_path, 3),
78        modified_files,
79        submodules,
80        subtrees,
81        ahead: status.ahead,
82        behind: status.behind,
83        remote_branch: status.upstream,
84    })
85}
86
87struct StatusEntry {
88    path: String,
89    submodule: Option<String>,
90    unmerged: bool,
91}
92
93struct StatusResult {
94    upstream: Option<String>,
95    ahead: usize,
96    behind: usize,
97    entries: Vec<StatusEntry>,
98}
99
100/// Parse `git status --porcelain=2 --branch` output without collapsing
101/// submodule gitlinks into ordinary modified-file rows.
102fn status_porcelain2(path: &Path) -> Option<StatusResult> {
103    let out = super::output_with_timeout(
104        git_read(path).args(["status", "--porcelain=2", "--branch", "-z"]),
105        std::time::Duration::from_secs(10),
106    )
107    .ok()?;
108    if !out.status.success() {
109        return None;
110    }
111    let text = String::from_utf8_lossy(&out.stdout);
112    let mut branch = String::new();
113    let mut upstream = None;
114    let mut ahead = 0usize;
115    let mut behind = 0usize;
116    let mut entries = Vec::new();
117
118    let mut skip_rename_source = false;
119    for line in text.split('\0').filter(|line| !line.is_empty()) {
120        if skip_rename_source {
121            skip_rename_source = false;
122            continue;
123        }
124        if let Some(value) = line.strip_prefix("# branch.head ") {
125            branch = value.trim().to_string();
126        } else if let Some(value) = line.strip_prefix("# branch.upstream ") {
127            let value = value.trim();
128            if !value.is_empty() {
129                upstream = Some(value.to_string());
130            }
131        } else if let Some(value) = line.strip_prefix("# branch.ab ") {
132            let mut parts = value.split_whitespace();
133            if let Some(value) = parts.next() {
134                ahead = value.trim_start_matches('+').parse().unwrap_or(0);
135            }
136            if let Some(value) = parts.next() {
137                behind = value.trim_start_matches('-').parse().unwrap_or(0);
138            }
139        } else if let Some(entry) = parse_status_entry(line) {
140            skip_rename_source = line.starts_with("2 ");
141            entries.push(entry);
142        }
143    }
144    if branch.is_empty() {
145        return None;
146    }
147    Some(StatusResult {
148        upstream,
149        ahead,
150        behind,
151        entries,
152    })
153}
154
155fn parse_status_entry(line: &str) -> Option<StatusEntry> {
156    if let Some(path) = line.strip_prefix("? ") {
157        return Some(StatusEntry {
158            path: path.trim().to_string(),
159            submodule: None,
160            unmerged: false,
161        });
162    }
163    let (field_count, unmerged) = if line.starts_with("1 ") {
164        (9, false)
165    } else if line.starts_with("2 ") {
166        (10, false)
167    } else if line.starts_with("u ") {
168        (11, true)
169    } else {
170        return None;
171    };
172    let fields = line.splitn(field_count, ' ').collect::<Vec<_>>();
173    if fields.len() != field_count {
174        return None;
175    }
176    let token = fields.get(2)?.to_string();
177    let path = fields.last()?.split('\t').next()?.to_string();
178    Some(StatusEntry {
179        path,
180        submodule: token.starts_with('S').then_some(token),
181        unmerged,
182    })
183}
184
185fn submodule_status(path: &Path) -> Option<Vec<SubmoduleInfo>> {
186    if !path.join(".gitmodules").exists() {
187        return Some(Vec::new());
188    }
189    let out = super::output_with_timeout(
190        git_read(path).args(["submodule", "status", "--recursive"]),
191        std::time::Duration::from_secs(10),
192    )
193    .ok()?;
194    if !out.status.success() {
195        return None;
196    }
197    Some(
198        String::from_utf8_lossy(&out.stdout)
199            .lines()
200            .filter_map(parse_submodule_status_line)
201            .collect(),
202    )
203}
204
205fn parse_submodule_status_line(line: &str) -> Option<SubmoduleInfo> {
206    let marker = line.chars().next()?;
207    let rest = line.get(1..)?.trim_start();
208    let mut fields = rest.splitn(2, ' ');
209    let commit = fields.next()?;
210    if commit.len() < 7
211        || !commit
212            .chars()
213            .all(|character| character.is_ascii_hexdigit())
214    {
215        return None;
216    }
217    let path = fields.next()?.split(" (").next()?.trim();
218    if path.is_empty() {
219        return None;
220    }
221    let commit_state = match marker {
222        '+' => SubmoduleCommitState::CommitChanged,
223        '-' => SubmoduleCommitState::Uninitialized,
224        'U' => SubmoduleCommitState::Conflict,
225        _ => SubmoduleCommitState::InSync,
226    };
227    Some(SubmoduleInfo {
228        path: path.to_string(),
229        commit_state,
230        modified_content: false,
231        untracked_content: false,
232    })
233}
234
235/// Advisory cross-process lockfile for git fetch. Created with O_CREAT|O_EXCL.
236/// Returns the lock path if acquired, None if another process holds it (< 120s old).
237fn try_fetch_lock(path: &Path) -> Option<std::path::PathBuf> {
238    use std::hash::{Hash, Hasher};
239    let mut h = std::collections::hash_map::DefaultHasher::new();
240    path.hash(&mut h);
241    let hash = h.finish();
242    let lock_path = std::env::temp_dir().join(format!("wsx-fetch-{:x}.lock", hash));
243    // Check if existing lock is stale (> 120s) — crashed process protection
244    if let Ok(meta) = std::fs::metadata(&lock_path) {
245        let age = meta
246            .modified()
247            .ok()
248            .and_then(|t| t.elapsed().ok())
249            .map(|d| d.as_secs())
250            .unwrap_or(u64::MAX);
251        if age < 120 {
252            return None; // another process holds a fresh lock
253        }
254        let _ = std::fs::remove_file(&lock_path); // stale, clean up
255    }
256    // Try atomic create with O_CREAT|O_EXCL
257    use std::fs::OpenOptions;
258    use std::io::Write;
259    let mut opts = OpenOptions::new();
260    opts.write(true).create_new(true);
261    match opts.open(&lock_path) {
262        Ok(mut f) => {
263            let _ = write!(f, "{}", std::process::id());
264            Some(lock_path)
265        }
266        Err(_) => None, // lost the race
267    }
268}
269
270/// RAII guard that removes the lockfile on drop.
271struct FetchLockGuard(std::path::PathBuf);
272impl Drop for FetchLockGuard {
273    fn drop(&mut self) {
274        let _ = std::fs::remove_file(&self.0);
275    }
276}
277
278/// Run `git fetch` — uses `output_with_timeout` for process-group cleanup on timeout.
279/// Advisory cross-process lockfile prevents duplicate concurrent fetches from multiple instances.
280pub fn git_fetch(path: &Path) -> FetchOutcome {
281    let Some(lock_path) = try_fetch_lock(path) else {
282        // Another instance is handling this fetch; report success so backoff stays low.
283        return FetchOutcome {
284            success: true,
285            reason: None,
286        };
287    };
288    let _lock = FetchLockGuard(lock_path);
289    let result = super::output_with_timeout(
290        git_cmd(path).args(["fetch", "--no-tags", "--quiet"]),
291        std::time::Duration::from_secs(10),
292    );
293    match result {
294        Err(e) if e.kind() == std::io::ErrorKind::TimedOut => FetchOutcome {
295            success: false,
296            reason: Some(FetchFailReason::Timeout),
297        },
298        Err(_) => FetchOutcome {
299            success: false,
300            reason: Some(FetchFailReason::Network),
301        },
302        Ok(out) if out.status.success() => FetchOutcome {
303            success: true,
304            reason: None,
305        },
306        Ok(out) => {
307            let stderr = String::from_utf8_lossy(&out.stderr);
308            FetchOutcome {
309                success: false,
310                reason: Some(classify_fetch_error(&stderr)),
311            }
312        }
313    }
314}
315
316fn classify_fetch_error(stderr: &str) -> FetchFailReason {
317    let lower = stderr.to_lowercase();
318    if lower.contains("authentication failed")
319        || lower.contains("permission denied")
320        || lower.contains("could not read username")
321        || lower.contains("invalid username or password")
322        || lower.contains("repository not found")
323    {
324        FetchFailReason::Auth
325    } else {
326        FetchFailReason::Network
327    }
328}
329
330pub fn current_branch(path: &Path) -> Option<String> {
331    let out = super::output_with_timeout(
332        git_read(path).args(["branch", "--show-current"]),
333        std::time::Duration::from_secs(5),
334    )
335    .ok()?;
336    if !out.status.success() {
337        return None;
338    }
339    let branch = String::from_utf8_lossy(&out.stdout).trim().to_string();
340    if branch.is_empty() {
341        None
342    } else {
343        Some(branch)
344    }
345}
346
347fn recent_commits(path: &Path, n: usize) -> Vec<CommitSummary> {
348    let Ok(out) = super::output_with_timeout(
349        git_read(path).args(["log", "--oneline", &format!("-{}", n)]),
350        std::time::Duration::from_secs(5),
351    ) else {
352        return vec![];
353    };
354    if !out.status.success() {
355        return vec![];
356    }
357    String::from_utf8_lossy(&out.stdout)
358        .lines()
359        .filter_map(|line| {
360            let mut parts = line.splitn(2, ' ');
361            let hash = parts.next()?.to_string();
362            let message = parts.next().unwrap_or("").to_string();
363            Some(CommitSummary { hash, message })
364        })
365        .collect()
366}
367
368fn git_read(path: &Path) -> Command {
369    let mut cmd = git_cmd(path);
370    cmd.arg("--no-optional-locks");
371    cmd
372}
373
374#[cfg(test)]
375mod tests {
376    use super::{
377        get_git_info, parse_status_entry, parse_submodule_status_line, try_fetch_lock,
378        FetchLockGuard,
379    };
380    use crate::model::workspace::SubmoduleCommitState;
381    use std::fs;
382    use std::path::{Path, PathBuf};
383    use std::process::Command;
384    use std::sync::atomic::{AtomicUsize, Ordering};
385    use std::time::{SystemTime, UNIX_EPOCH};
386
387    #[test]
388    fn fetch_lock_acquired_on_fresh_path() {
389        let path = PathBuf::from("/tmp/wsx_test_lock_fresh");
390        let result = try_fetch_lock(&path);
391        assert!(result.is_some(), "should acquire lock on a fresh path");
392        let lock_path = result.unwrap();
393        assert!(lock_path.exists(), "lockfile should exist after acquire");
394        let _guard = FetchLockGuard(lock_path.clone());
395        // guard drop removes file
396        drop(_guard);
397        assert!(!lock_path.exists(), "lockfile should be removed on drop");
398    }
399
400    #[test]
401    fn fetch_lock_fails_when_held() {
402        let path = PathBuf::from("/tmp/wsx_test_lock_held");
403        let lock1 = try_fetch_lock(&path);
404        assert!(lock1.is_some(), "first acquire should succeed");
405        let lock2 = try_fetch_lock(&path);
406        assert!(
407            lock2.is_none(),
408            "second acquire should fail while first is held"
409        );
410        drop(lock1.map(FetchLockGuard));
411    }
412
413    #[test]
414    fn fetch_lock_different_paths_independent() {
415        let path_a = PathBuf::from("/tmp/wsx_test_lock_a");
416        let path_b = PathBuf::from("/tmp/wsx_test_lock_b");
417        let lock_a = try_fetch_lock(&path_a);
418        let lock_b = try_fetch_lock(&path_b);
419        assert!(lock_a.is_some(), "lock for path_a should succeed");
420        assert!(
421            lock_b.is_some(),
422            "lock for path_b should succeed independently"
423        );
424        drop(lock_a.map(FetchLockGuard));
425        drop(lock_b.map(FetchLockGuard));
426    }
427
428    static NEXT_TEMP_ID: AtomicUsize = AtomicUsize::new(0);
429
430    fn git(path: &Path, args: &[&str]) {
431        let status = Command::new("git")
432            .arg("-C")
433            .arg(path)
434            .args(args)
435            .status()
436            .expect("git command should run");
437        assert!(
438            status.success(),
439            "git command failed: git -C {:?} {:?}",
440            path,
441            args
442        );
443    }
444
445    fn init_temp_repo() -> PathBuf {
446        let mut path = std::env::temp_dir();
447        let suffix = SystemTime::now()
448            .duration_since(UNIX_EPOCH)
449            .expect("clock should be after unix epoch")
450            .as_nanos();
451        let id = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed);
452        path.push(format!(
453            "wsx-git-info-test-{}-{}-{}",
454            std::process::id(),
455            suffix,
456            id
457        ));
458        fs::create_dir_all(&path).expect("temp repo dir should be created");
459
460        git(&path, &["init", "-q"]);
461        git(&path, &["config", "user.email", "test@example.com"]);
462        git(&path, &["config", "user.name", "Test User"]);
463        fs::write(path.join("tracked.txt"), "first\n").expect("tracked file should be written");
464        git(&path, &["add", "tracked.txt"]);
465        git(&path, &["commit", "-m", "init", "-q"]);
466        path
467    }
468
469    #[test]
470    fn get_git_info_reports_dirty_file() {
471        let repo = init_temp_repo();
472        fs::write(repo.join("tracked.txt"), "changed\n").expect("tracked file should be updated");
473        let info = get_git_info(&repo, "main", &[]).expect("git info should be available");
474        assert!(
475            info.modified_files.iter().any(|f| f == "tracked.txt"),
476            "expected tracked.txt in modified files, got {:?}",
477            info.modified_files
478        );
479        let _ = fs::remove_dir_all(repo);
480    }
481
482    #[test]
483    fn porcelain_submodule_entry_retains_structured_flags_and_spaced_path() {
484        let entry = parse_status_entry(
485            "1 .M SCMU 160000 160000 160000 aaaaaaa bbbbbbb vendor/module with spaces",
486        )
487        .unwrap();
488        assert_eq!(entry.path, "vendor/module with spaces");
489        assert_eq!(entry.submodule.as_deref(), Some("SCMU"));
490        assert!(!entry.unmerged);
491    }
492
493    #[test]
494    fn nul_porcelain_preserves_spaced_and_renamed_paths() {
495        let repo = init_temp_repo();
496        fs::write(repo.join("old name.txt"), "one\n").unwrap();
497        git(&repo, &["add", "old name.txt"]);
498        git(&repo, &["commit", "-m", "add spaced file", "-q"]);
499        git(&repo, &["mv", "old name.txt", "new name.txt"]);
500
501        let info = get_git_info(&repo, "main", &[]).unwrap();
502
503        assert!(
504            info.modified_files
505                .iter()
506                .any(|path| path == "new name.txt"),
507            "{:?}",
508            info.modified_files
509        );
510        assert!(!info
511            .modified_files
512            .iter()
513            .any(|path| path == "old name.txt"));
514        let _ = fs::remove_dir_all(repo);
515    }
516
517    #[test]
518    fn submodule_status_marker_maps_parent_gitlink_state() {
519        for (marker, expected) in [
520            (' ', SubmoduleCommitState::InSync),
521            ('+', SubmoduleCommitState::CommitChanged),
522            ('-', SubmoduleCommitState::Uninitialized),
523            ('U', SubmoduleCommitState::Conflict),
524        ] {
525            let line = format!("{marker}0123456789abcdef vendor/module (heads/main)");
526            let info = parse_submodule_status_line(&line).unwrap();
527            assert_eq!(info.path, "vendor/module");
528            assert_eq!(info.commit_state, expected);
529        }
530    }
531
532    #[test]
533    fn submodule_changes_are_separate_from_ordinary_local_files() {
534        let child = init_temp_repo();
535        let parent = init_temp_repo();
536        git(
537            &parent,
538            &[
539                "-c",
540                "protocol.file.allow=always",
541                "submodule",
542                "add",
543                child.to_str().unwrap(),
544                "vendor/module with spaces",
545            ],
546        );
547        git(&parent, &["commit", "-m", "add submodule", "-q"]);
548        let checkout = parent.join("vendor/module with spaces");
549        fs::write(checkout.join("tracked.txt"), "dirty\n").unwrap();
550
551        let dirty = get_git_info(&parent, "main", &[]).unwrap();
552
553        assert!(dirty.modified_files.is_empty());
554        let submodule = &dirty.submodules.as_ref().unwrap()[0];
555        assert_eq!(submodule.path, "vendor/module with spaces");
556        assert_eq!(submodule.commit_state, SubmoduleCommitState::InSync);
557        assert!(submodule.modified_content);
558
559        git(&checkout, &["config", "user.email", "test@example.com"]);
560        git(&checkout, &["config", "user.name", "Test User"]);
561        git(&checkout, &["add", "tracked.txt"]);
562        git(&checkout, &["commit", "-m", "advance submodule", "-q"]);
563        let advanced = get_git_info(&parent, "main", &[]).unwrap();
564        let submodule = &advanced.submodules.as_ref().unwrap()[0];
565        assert_eq!(submodule.commit_state, SubmoduleCommitState::CommitChanged);
566
567        let _ = fs::remove_dir_all(parent);
568        let _ = fs::remove_dir_all(child);
569    }
570
571    #[test]
572    fn configured_subtree_changes_are_separate_from_local_files() {
573        let repo = init_temp_repo();
574        fs::create_dir_all(repo.join("vendor/asched")).unwrap();
575        fs::write(repo.join("vendor/asched/source.rs"), "one\n").unwrap();
576        fs::write(repo.join("ordinary.txt"), "one\n").unwrap();
577        git(&repo, &["add", "."]);
578        git(&repo, &["commit", "-m", "add files", "-q"]);
579        fs::write(repo.join("vendor/asched/source.rs"), "two\n").unwrap();
580        fs::write(repo.join("ordinary.txt"), "two\n").unwrap();
581
582        let info = get_git_info(&repo, "main", &[PathBuf::from("vendor/asched")]).unwrap();
583
584        assert_eq!(info.modified_files, ["ordinary.txt"]);
585        assert_eq!(info.subtrees.len(), 1);
586        assert_eq!(info.subtrees[0].path, "vendor/asched");
587        assert_eq!(info.subtrees[0].modified_files, ["vendor/asched/source.rs"]);
588        let _ = fs::remove_dir_all(repo);
589    }
590
591    #[test]
592    fn get_git_info_returns_none_when_status_fails() {
593        let repo = init_temp_repo();
594        // Corrupt index so branch detection still works but status exits non-zero.
595        fs::write(repo.join(".git").join("index"), "broken").expect("index should be overwritten");
596
597        let info = get_git_info(&repo, "main", &[]);
598        assert!(
599            info.is_none(),
600            "expected None when status fails, got {:?}",
601            info
602        );
603        let _ = fs::remove_dir_all(repo);
604    }
605}