1use 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 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
100fn 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
235fn 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 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; }
254 let _ = std::fs::remove_file(&lock_path); }
256 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, }
268}
269
270struct FetchLockGuard(std::path::PathBuf);
272impl Drop for FetchLockGuard {
273 fn drop(&mut self) {
274 let _ = std::fs::remove_file(&self.0);
275 }
276}
277
278pub fn git_fetch(path: &Path) -> FetchOutcome {
281 let Some(lock_path) = try_fetch_lock(path) else {
282 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 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 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}