1use 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 let (branch, remote_branch, ahead, behind, modified_files) = status_porcelain2(worktree_path)?;
17 let _ = branch; 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
30fn 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 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 let path_str = if line.starts_with("2 ") {
69 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 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 if branch.is_empty() {
94 return None;
95 }
96 }
97
98 Some((branch, upstream, ahead, behind, modified_files))
99}
100
101fn 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 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; }
120 let _ = std::fs::remove_file(&lock_path); }
122 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, }
134}
135
136struct FetchLockGuard(std::path::PathBuf);
138impl Drop for FetchLockGuard {
139 fn drop(&mut self) {
140 let _ = std::fs::remove_file(&self.0);
141 }
142}
143
144pub fn git_fetch(path: &Path) -> FetchOutcome {
147 let Some(lock_path) = try_fetch_lock(path) else {
148 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 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 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}