1use crate::error::{AppError, Result};
6use serde::{Deserialize, Serialize};
7use std::path::{Path, PathBuf};
8use std::process::{Command, Stdio};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct GitStatus {
13 pub branch: String,
14 pub is_clean: bool,
15 pub staged: Vec<FileChange>,
16 pub unstaged: Vec<FileChange>,
17 pub untracked: Vec<PathBuf>,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct FileChange {
23 pub path: PathBuf,
24 pub status: ChangeStatus,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
29pub enum ChangeStatus {
30 Added,
31 Modified,
32 Deleted,
33 Renamed,
34 Copied,
35 Unknown,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct GitDiff {
41 pub files: Vec<FileDiff>,
42 pub stats: DiffStats,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct FileDiff {
48 pub path: PathBuf,
49 pub hunks: Vec<DiffHunk>,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct DiffHunk {
55 pub header: String,
56 pub lines: Vec<DiffLine>,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct DiffLine {
62 pub line_type: DiffLineType,
63 pub content: String,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub enum DiffLineType {
68 Context,
69 Added,
70 Removed,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct DiffStats {
76 pub files_changed: usize,
77 pub insertions: usize,
78 pub deletions: usize,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct CommitInfo {
84 pub hash: String,
85 pub short_hash: String,
86 pub author: String,
87 pub date: String,
88 pub message: String,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct BranchInfo {
94 pub name: String,
95 pub is_current: bool,
96 pub is_remote: bool,
97 pub upstream: Option<String>,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct GitWorktreeInfo {
103 pub path: PathBuf,
105 pub branch: Option<String>,
107 pub head: Option<String>,
109 pub is_bare: bool,
111 pub is_detached: bool,
113}
114
115pub struct GitTools {
117 work_dir: Option<PathBuf>,
118}
119
120impl Default for GitTools {
121 fn default() -> Self {
122 Self::new(None)
123 }
124}
125
126impl GitTools {
127 pub fn new(work_dir: Option<PathBuf>) -> Self {
128 Self { work_dir }
129 }
130
131 pub fn with_work_dir(mut self, dir: PathBuf) -> Self {
133 self.work_dir = Some(dir);
134 self
135 }
136
137 fn run_git(&self, args: &[&str]) -> Result<String> {
138 self.run_git_in(self.work_dir.as_deref(), args)
139 }
140
141 fn run_git_in(&self, dir: Option<&Path>, args: &[&str]) -> Result<String> {
142 let mut cmd = Command::new("git");
143 if let Some(dir) = dir {
144 cmd.current_dir(dir);
145 }
146 cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
147
148 let output = cmd.output().map_err(AppError::Io)?;
149
150 if output.status.success() {
151 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
152 } else {
153 Err(AppError::Io(std::io::Error::other(
154 String::from_utf8_lossy(&output.stderr).to_string(),
155 )))
156 }
157 }
158
159 fn run_git_status_only(&self, dir: Option<&Path>, args: &[&str]) -> Result<bool> {
160 let mut cmd = Command::new("git");
161 if let Some(dir) = dir {
162 cmd.current_dir(dir);
163 }
164 let status = cmd.args(args).status().map_err(AppError::Io)?;
165 Ok(status.success())
166 }
167
168 pub fn rev_parse_toplevel(&self) -> Result<PathBuf> {
170 self.run_git(&["rev-parse", "--show-toplevel"])
171 .map(PathBuf::from)
172 }
173
174 pub fn current_branch(&self) -> Result<String> {
176 self.run_git(&["branch", "--show-current"])
177 }
178
179 pub fn branch_exists(&self, branch: &str) -> Result<bool> {
181 self.run_git_status_only(
182 self.work_dir.as_deref(),
183 &[
184 "show-ref",
185 "--verify",
186 "--quiet",
187 &format!("refs/heads/{branch}"),
188 ],
189 )
190 }
191
192 pub fn path_is_git_repo(&self) -> Result<bool> {
194 self.run_git_status_only(
195 self.work_dir.as_deref(),
196 &["rev-parse", "--is-inside-work-tree"],
197 )
198 }
199
200 pub fn worktree_list(&self) -> Result<Vec<GitWorktreeInfo>> {
202 let output = self.run_git(&["worktree", "list", "--porcelain"])?;
203 parse_worktree_list(&output)
204 }
205
206 pub fn worktree_add(
208 &self,
209 path: &Path,
210 branch: &str,
211 base_branch: &str,
212 create_branch: bool,
213 ) -> Result<GitWorktreeInfo> {
214 let path_str = path.to_string_lossy().to_string();
215 let branch_exists = self.branch_exists(branch)?;
216
217 let mut args: Vec<&str> = vec!["worktree", "add"];
218 if create_branch && !branch_exists {
219 args.push("-b");
220 args.push(branch);
221 args.push(&path_str);
222 args.push(base_branch);
223 } else {
224 args.push(&path_str);
225 args.push(branch);
226 }
227
228 self.run_git(&args)?;
229 let desired_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
230 self.worktree_list()?
231 .into_iter()
232 .find(|info| {
233 info.path == desired_path
234 || info.path.canonicalize().ok().as_ref() == Some(&desired_path)
235 })
236 .ok_or_else(|| {
237 AppError::Io(std::io::Error::other(
238 "Created worktree was not discoverable",
239 ))
240 })
241 }
242
243 pub fn worktree_remove(&self, path: &Path, force: bool) -> Result<()> {
245 let path_str = path.to_string_lossy().to_string();
246 if force {
247 self.run_git(&["worktree", "remove", "--force", &path_str])?;
248 } else {
249 self.run_git(&["worktree", "remove", &path_str])?;
250 }
251 Ok(())
252 }
253
254 pub fn worktree_prune(&self) -> Result<()> {
256 self.run_git(&["worktree", "prune"])?;
257 Ok(())
258 }
259
260 pub fn is_worktree_clean(&self, path: &Path) -> Result<bool> {
262 let status = self.run_git_in(Some(path), &["status", "--porcelain"])?;
263 Ok(status.trim().is_empty())
264 }
265
266 pub fn status(&self) -> Result<GitStatus> {
268 let branch = self.run_git(&["branch", "--show-current"])?;
269 let porcelain = self.run_git(&["status", "--porcelain"])?;
270
271 let mut staged = Vec::new();
272 let mut unstaged = Vec::new();
273 let mut untracked = Vec::new();
274
275 for line in porcelain.lines() {
276 if line.len() < 3 {
277 continue;
278 }
279 let index_status = line.chars().next().unwrap_or(' ');
280 let worktree_status = line.chars().nth(1).unwrap_or(' ');
281 let path = PathBuf::from(&line[3..]);
282
283 if index_status == '?' {
284 untracked.push(path);
285 } else {
286 if index_status != ' ' {
287 staged.push(FileChange {
288 path: path.clone(),
289 status: parse_status(index_status),
290 });
291 }
292 if worktree_status != ' ' {
293 unstaged.push(FileChange {
294 path,
295 status: parse_status(worktree_status),
296 });
297 }
298 }
299 }
300
301 Ok(GitStatus {
302 branch,
303 is_clean: staged.is_empty() && unstaged.is_empty() && untracked.is_empty(),
304 staged,
305 unstaged,
306 untracked,
307 })
308 }
309
310 pub fn diff(&self, staged: bool, path: Option<&Path>) -> Result<String> {
312 let mut args = vec!["diff"];
313 if staged {
314 args.push("--staged");
315 }
316 if let Some(p) = path {
317 args.push("--");
318 let path_str = p.to_string_lossy();
319 return self.run_git(
321 &[
322 "diff",
323 if staged { "--staged" } else { "" },
324 "--",
325 &path_str,
326 ]
327 .into_iter()
328 .filter(|s| !s.is_empty())
329 .collect::<Vec<_>>(),
330 );
331 }
332 self.run_git(&args)
333 }
334
335 pub fn log(&self, limit: Option<usize>, path: Option<&Path>) -> Result<Vec<CommitInfo>> {
337 let limit_str = limit.unwrap_or(10).to_string();
338 let format = "--format=%H|%h|%an|%ai|%s";
339
340 let output = if let Some(p) = path {
341 self.run_git(&["log", "-n", &limit_str, format, "--", &p.to_string_lossy()])?
342 } else {
343 self.run_git(&["log", "-n", &limit_str, format])?
344 };
345
346 let commits = output
347 .lines()
348 .filter_map(|line| {
349 let parts: Vec<&str> = line.splitn(5, '|').collect();
350 if parts.len() >= 5 {
351 Some(CommitInfo {
352 hash: parts[0].to_string(),
353 short_hash: parts[1].to_string(),
354 author: parts[2].to_string(),
355 date: parts[3].to_string(),
356 message: parts[4].to_string(),
357 })
358 } else {
359 None
360 }
361 })
362 .collect();
363
364 Ok(commits)
365 }
366
367 pub fn commit(&self, message: &str, all: bool) -> Result<CommitInfo> {
369 if all {
370 self.run_git(&["add", "-A"])?;
371 }
372
373 self.run_git(&["commit", "-m", message])?;
374
375 let commits = self.log(Some(1), None)?;
377 commits
378 .into_iter()
379 .next()
380 .ok_or_else(|| AppError::Io(std::io::Error::other("Failed to get commit info")))
381 }
382
383 pub fn undo(&self, soft: bool) -> Result<String> {
385 let flag = if soft { "--soft" } else { "--mixed" };
386 self.run_git(&["reset", flag, "HEAD~1"])
387 }
388
389 pub fn branches(&self, all: bool) -> Result<Vec<BranchInfo>> {
391 let args = if all {
392 vec!["branch", "-a"]
393 } else {
394 vec!["branch"]
395 };
396 let output = self.run_git(&args)?;
397
398 let branches = output
399 .lines()
400 .map(|line| {
401 let is_current = line.starts_with('*');
402 let name = line.trim_start_matches(['*', ' ']).to_string();
403 let is_remote = name.starts_with("remotes/");
404 BranchInfo {
405 name,
406 is_current,
407 is_remote,
408 upstream: None,
409 }
410 })
411 .collect();
412
413 Ok(branches)
414 }
415
416 pub fn checkout(&self, target: &str, create: bool) -> Result<String> {
418 if create {
419 self.run_git(&["checkout", "-b", target])
420 } else {
421 self.run_git(&["checkout", target])
422 }
423 }
424
425 pub fn stash(&self, pop: bool, message: Option<&str>) -> Result<String> {
427 if pop {
428 self.run_git(&["stash", "pop"])
429 } else if let Some(msg) = message {
430 self.run_git(&["stash", "push", "-m", msg])
431 } else {
432 self.run_git(&["stash"])
433 }
434 }
435
436 pub fn blame(&self, path: &Path, line_range: Option<(usize, usize)>) -> Result<String> {
438 if let Some((start, end)) = line_range {
439 self.run_git(&[
440 "blame",
441 "-L",
442 &format!("{},{}", start, end),
443 &path.to_string_lossy(),
444 ])
445 } else {
446 self.run_git(&["blame", &path.to_string_lossy()])
447 }
448 }
449}
450
451fn parse_status(c: char) -> ChangeStatus {
452 match c {
453 'A' => ChangeStatus::Added,
454 'M' => ChangeStatus::Modified,
455 'D' => ChangeStatus::Deleted,
456 'R' => ChangeStatus::Renamed,
457 'C' => ChangeStatus::Copied,
458 _ => ChangeStatus::Unknown,
459 }
460}
461
462fn parse_worktree_list(output: &str) -> Result<Vec<GitWorktreeInfo>> {
463 let mut worktrees = Vec::new();
464 let mut current_path: Option<PathBuf> = None;
465 let mut current_branch: Option<String> = None;
466 let mut current_head: Option<String> = None;
467 let mut is_bare = false;
468 let mut is_detached = false;
469
470 let flush = |worktrees: &mut Vec<GitWorktreeInfo>,
471 current_path: &mut Option<PathBuf>,
472 current_branch: &mut Option<String>,
473 current_head: &mut Option<String>,
474 is_bare: &mut bool,
475 is_detached: &mut bool| {
476 if let Some(path) = current_path.take() {
477 worktrees.push(GitWorktreeInfo {
478 path,
479 branch: current_branch.take(),
480 head: current_head.take(),
481 is_bare: *is_bare,
482 is_detached: *is_detached,
483 });
484 }
485 *is_bare = false;
486 *is_detached = false;
487 };
488
489 for line in output.lines() {
490 if line.trim().is_empty() {
491 flush(
492 &mut worktrees,
493 &mut current_path,
494 &mut current_branch,
495 &mut current_head,
496 &mut is_bare,
497 &mut is_detached,
498 );
499 continue;
500 }
501
502 if let Some(value) = line.strip_prefix("worktree ") {
503 flush(
504 &mut worktrees,
505 &mut current_path,
506 &mut current_branch,
507 &mut current_head,
508 &mut is_bare,
509 &mut is_detached,
510 );
511 current_path = Some(PathBuf::from(value.trim()));
512 } else if let Some(value) = line.strip_prefix("HEAD ") {
513 current_head = Some(value.trim().to_string());
514 } else if let Some(value) = line.strip_prefix("branch refs/heads/") {
515 current_branch = Some(value.trim().to_string());
516 } else if line.trim() == "bare" {
517 is_bare = true;
518 } else if line.trim() == "detached" {
519 is_detached = true;
520 }
521 }
522
523 flush(
524 &mut worktrees,
525 &mut current_path,
526 &mut current_branch,
527 &mut current_head,
528 &mut is_bare,
529 &mut is_detached,
530 );
531
532 if worktrees.is_empty() && !output.trim().is_empty() {
533 return Err(AppError::Io(std::io::Error::other(
534 "Failed to parse git worktree list output",
535 )));
536 }
537
538 Ok(worktrees)
539}
540
541#[cfg(test)]
542mod tests {
543 use super::*;
544 #[cfg(not(target_os = "windows"))]
545 use std::fs;
546 #[cfg(not(target_os = "windows"))]
547 use tempfile::tempdir;
548
549 #[cfg(not(target_os = "windows"))]
550 fn init_test_repo() -> tempfile::TempDir {
551 let temp = tempdir().unwrap();
552 let repo = temp.path();
553
554 let git = GitTools::new(Some(repo.to_path_buf()));
555 git.run_git(&["init", "-b", "main"]).unwrap();
556 git.run_git(&["config", "user.email", "test@example.com"])
557 .unwrap();
558 git.run_git(&["config", "user.name", "Test User"]).unwrap();
559 fs::write(repo.join("README.md"), "hello\n").unwrap();
560 git.run_git(&["add", "README.md"]).unwrap();
561 git.run_git(&["commit", "-m", "initial"]).unwrap();
562 temp
563 }
564
565 #[test]
566 fn test_parse_status() {
567 assert!(matches!(parse_status('A'), ChangeStatus::Added));
568 assert!(matches!(parse_status('M'), ChangeStatus::Modified));
569 assert!(matches!(parse_status('D'), ChangeStatus::Deleted));
570 assert!(matches!(parse_status('R'), ChangeStatus::Renamed));
571 assert!(matches!(parse_status('C'), ChangeStatus::Copied));
572 assert!(matches!(parse_status('X'), ChangeStatus::Unknown));
573 }
574
575 #[test]
576 fn test_git_tools_new() {
577 let tools = GitTools::new(None);
578 assert!(tools.work_dir.is_none());
579 }
580
581 #[test]
582 fn test_git_tools_with_work_dir() {
583 let tools = GitTools::new(Some(PathBuf::from("/tmp/test")));
584 assert_eq!(tools.work_dir, Some(PathBuf::from("/tmp/test")));
585 }
586
587 #[test]
588 fn test_status_in_git_repo() {
589 let tools = GitTools::new(None);
591 let status = tools.status();
592 assert!(status.is_ok());
594 }
596
597 #[test]
598 fn test_log_in_git_repo() {
599 let tools = GitTools::new(None);
600 let log = tools.log(Some(5), None);
601 assert!(log.is_ok());
602 let log = log.unwrap();
603 assert!(!log.is_empty());
604 }
605
606 #[test]
607 fn test_branches() {
608 let tools = GitTools::new(None);
609 let branches = tools.branches(false);
610 assert!(branches.is_ok());
611 let branches = branches.unwrap();
612 assert!(!branches.is_empty());
614 }
615
616 #[test]
617 fn test_parse_worktree_list() {
618 let parsed = parse_worktree_list(
619 "worktree /tmp/repo\nHEAD abc123\nbranch refs/heads/main\n\nworktree /tmp/repo-feature\nHEAD def456\nbranch refs/heads/feature\n\n",
620 )
621 .unwrap();
622 assert_eq!(parsed.len(), 2);
623 assert_eq!(parsed[0].branch.as_deref(), Some("main"));
624 assert_eq!(parsed[1].branch.as_deref(), Some("feature"));
625 }
626
627 #[test]
628 #[cfg(not(target_os = "windows"))]
629 fn test_worktree_lifecycle() {
630 let temp = init_test_repo();
631 let worktree_parent = tempdir().unwrap();
632 let repo = temp.path();
633 let tools = GitTools::new(Some(repo.to_path_buf()));
634 let worktree_path = worktree_parent.path().join("feature-worktree");
635
636 let info = tools
637 .worktree_add(&worktree_path, "gestura/test-feature", "main", true)
638 .unwrap();
639 let normalized_worktree_path = worktree_path.canonicalize().unwrap();
640 assert_eq!(info.path, normalized_worktree_path);
641 assert_eq!(info.branch.as_deref(), Some("gestura/test-feature"));
642 assert!(
643 tools
644 .worktree_list()
645 .unwrap()
646 .iter()
647 .any(|entry| entry.path == normalized_worktree_path)
648 );
649 assert!(tools.is_worktree_clean(&worktree_path).unwrap());
650
651 fs::write(worktree_path.join("README.md"), "changed\n").unwrap();
652 assert!(!tools.is_worktree_clean(&worktree_path).unwrap());
653
654 tools.worktree_remove(&worktree_path, true).unwrap();
655 tools.worktree_prune().unwrap();
656 assert!(!worktree_path.exists());
657 }
658}