1use std::fmt;
7use std::fs;
8use std::path::{Path, PathBuf};
9
10#[derive(Debug, Clone)]
15pub struct StatusEntry {
16 pub path: PathBuf,
17 pub staged: bool,
18}
19
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum DiffKind {
22 Context,
23 Added,
24 Removed,
25 HunkHeader,
27}
28
29#[derive(Debug, Clone)]
30pub struct DiffLine {
31 pub line_no: Option<usize>,
33 pub kind: DiffKind,
34 pub content: String,
35}
36
37#[derive(Debug, Clone)]
39pub struct CommitInfo {
40 pub oid: String,
42 pub summary: String,
43 pub author_name: String,
44 pub date_relative: String,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
49pub enum FileChangeStatus {
50 Modified,
51 Added,
52 Deleted,
53 Renamed,
54}
55
56impl FileChangeStatus {
57 pub fn indicator(&self) -> &'static str {
58 match self {
59 Self::Modified => "M",
60 Self::Added => "A",
61 Self::Deleted => "D",
62 Self::Renamed => "R",
63 }
64 }
65}
66
67#[derive(Debug, Clone)]
69pub struct FileChange {
70 pub path: PathBuf,
71 pub status: FileChangeStatus,
72}
73
74pub trait Vcs: Send {
85 fn status(&self) -> anyhow::Result<(Vec<StatusEntry>, Vec<StatusEntry>)>;
87
88 fn diff_file(&self, path: &Path, staged: bool) -> anyhow::Result<Vec<DiffLine>>;
92
93 fn stage_file(&self, path: &Path) -> anyhow::Result<()>;
94 fn unstage_file(&self, path: &Path) -> anyhow::Result<()>;
95
96 fn stage_lines(&self, path: &Path, selected: &[usize], diff: &[DiffLine])
99 -> anyhow::Result<()>;
100
101 fn unstage_lines(
103 &self,
104 path: &Path,
105 selected: &[usize],
106 diff: &[DiffLine],
107 ) -> anyhow::Result<()>;
108
109 fn commit(&self, message: &str) -> anyhow::Result<()>;
110
111 fn branches(&self) -> anyhow::Result<(Vec<String>, String)>;
113
114 fn create_branch(&self, name: &str) -> anyhow::Result<()>;
115 fn checkout_branch(&self, name: &str) -> anyhow::Result<()>;
116
117 fn log_commits(&self, max: usize, filter: &str) -> anyhow::Result<Vec<CommitInfo>>;
120
121 fn commit_files(&self, oid: &str) -> anyhow::Result<Vec<FileChange>>;
124
125 fn commit_diff(&self, oid: &str, path: &Path) -> anyhow::Result<Vec<DiffLine>>;
128}
129
130pub struct GitVcs {
135 pub root: PathBuf,
137 repo: git2::Repository,
138}
139
140impl fmt::Debug for GitVcs {
142 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143 f.debug_struct("GitVcs")
144 .field("root", &self.root)
145 .finish_non_exhaustive()
146 }
147}
148
149impl GitVcs {
150 pub fn open(path: &Path) -> anyhow::Result<Self> {
151 let repo = git2::Repository::open(path)?;
152 Ok(Self {
153 root: path.to_owned(),
154 repo,
155 })
156 }
157}
158
159impl Vcs for GitVcs {
160 fn status(&self) -> anyhow::Result<(Vec<StatusEntry>, Vec<StatusEntry>)> {
165 let mut opts = git2::StatusOptions::new();
166 opts.include_untracked(true)
167 .recurse_untracked_dirs(true)
168 .include_ignored(false);
169
170 let statuses = self.repo.statuses(Some(&mut opts))?;
171
172 let mut staged = Vec::new();
173 let mut unstaged = Vec::new();
174
175 for entry in statuses.iter() {
176 let path = match entry.path() {
177 Some(p) => PathBuf::from(p),
178 None => continue,
179 };
180 let s = entry.status();
181
182 let is_staged = s.intersects(
183 git2::Status::INDEX_NEW
184 | git2::Status::INDEX_MODIFIED
185 | git2::Status::INDEX_DELETED
186 | git2::Status::INDEX_RENAMED
187 | git2::Status::INDEX_TYPECHANGE,
188 );
189 let is_unstaged = s.intersects(
190 git2::Status::WT_MODIFIED
191 | git2::Status::WT_DELETED
192 | git2::Status::WT_RENAMED
193 | git2::Status::WT_TYPECHANGE
194 | git2::Status::WT_NEW,
195 );
196
197 if is_staged {
198 staged.push(StatusEntry {
199 path: path.clone(),
200 staged: true,
201 });
202 }
203 if is_unstaged {
204 unstaged.push(StatusEntry {
205 path,
206 staged: false,
207 });
208 }
209 }
210
211 Ok((staged, unstaged))
212 }
213
214 fn diff_file(&self, path: &Path, staged: bool) -> anyhow::Result<Vec<DiffLine>> {
219 let mut diff_opts = git2::DiffOptions::new();
220 diff_opts
221 .pathspec(path.to_string_lossy().as_ref())
222 .context_lines(999_999);
224
225 let diff = if staged {
226 let head_tree = self.repo.head().ok().and_then(|h| h.peel_to_tree().ok());
228 self.repo
229 .diff_tree_to_index(head_tree.as_ref(), None, Some(&mut diff_opts))?
230 } else {
231 self.repo
233 .diff_index_to_workdir(None, Some(&mut diff_opts))?
234 };
235
236 let mut lines: Vec<DiffLine> = Vec::new();
237
238 diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
239 let content = String::from_utf8_lossy(line.content())
240 .trim_end_matches('\n')
241 .trim_end_matches('\r')
242 .to_string();
243
244 match line.origin() {
245 '+' => {
246 let line_no = line.new_lineno().map(|n| n as usize);
247 lines.push(DiffLine {
248 line_no,
249 kind: DiffKind::Added,
250 content,
251 });
252 }
253 '-' => {
254 let line_no = line.old_lineno().map(|n| n as usize);
255 lines.push(DiffLine {
256 line_no,
257 kind: DiffKind::Removed,
258 content,
259 });
260 }
261 ' ' => {
262 let line_no = line.new_lineno().map(|n| n as usize);
263 lines.push(DiffLine {
264 line_no,
265 kind: DiffKind::Context,
266 content,
267 });
268 }
269 'H' => {
270 lines.push(DiffLine {
272 line_no: None,
273 kind: DiffKind::HunkHeader,
274 content,
275 });
276 }
277 _ => {} }
279 true
280 })?;
281
282 if !staged && lines.is_empty() {
285 let abs = self.root.join(path);
286 let in_index = self
287 .repo
288 .index()
289 .ok()
290 .and_then(|idx| idx.get_path(path, 0))
291 .is_some();
292 if abs.exists()
293 && !in_index
294 && let Ok(content) = fs::read_to_string(&abs)
295 {
296 lines.push(DiffLine {
297 line_no: None,
298 kind: DiffKind::HunkHeader,
299 content: "@@ new file @@".to_string(),
300 });
301 for (i, line_content) in content.lines().enumerate() {
302 lines.push(DiffLine {
303 line_no: Some(i + 1),
304 kind: DiffKind::Added,
305 content: line_content.to_owned(),
306 });
307 }
308 }
309 }
310
311 Ok(lines)
312 }
313
314 fn stage_file(&self, path: &Path) -> anyhow::Result<()> {
319 let mut index = self.repo.index()?;
320 let abs = self.root.join(path);
321 if abs.exists() {
322 index.add_path(path)?;
323 } else {
324 index.remove_path(path)?;
326 }
327 index.write()?;
328 Ok(())
329 }
330
331 fn unstage_file(&self, path: &Path) -> anyhow::Result<()> {
332 match self.repo.head() {
334 Ok(head_ref) => {
335 let head_commit = head_ref.peel_to_commit()?;
336 let mut checkout_opts = git2::build::CheckoutBuilder::new();
337 checkout_opts.path(path).force();
338 self.repo
339 .reset_default(Some(head_commit.as_object()), [path])?;
340 }
341 Err(_) => {
342 let mut index = self.repo.index()?;
344 index.remove_path(path)?;
345 index.write()?;
346 }
347 }
348 Ok(())
349 }
350
351 fn stage_lines(
356 &self,
357 path: &Path,
358 selected: &[usize],
359 diff: &[DiffLine],
360 ) -> anyhow::Result<()> {
361 let index_lines = self.index_file_lines(path)?;
363
364 let new_content = apply_selected_lines(&index_lines, diff, selected, true)?;
366
367 self.write_index_blob(path, &new_content)
369 }
370
371 fn unstage_lines(
372 &self,
373 path: &Path,
374 selected: &[usize],
375 diff: &[DiffLine],
376 ) -> anyhow::Result<()> {
377 let index_lines = self.index_file_lines(path)?;
379 let new_content = apply_selected_lines(&index_lines, diff, selected, false)?;
380 self.write_index_blob(path, &new_content)
381 }
382
383 fn commit(&self, message: &str) -> anyhow::Result<()> {
388 let sig = self.repo.signature()?;
389 let mut index = self.repo.index()?;
390 let tree_oid = index.write_tree()?;
391 let tree = self.repo.find_tree(tree_oid)?;
392
393 match self.repo.head() {
394 Ok(head_ref) => {
395 let parent = head_ref.peel_to_commit()?;
396 self.repo
397 .commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent])?;
398 }
399 Err(_) => {
400 self.repo
402 .commit(Some("HEAD"), &sig, &sig, message, &tree, &[])?;
403 }
404 }
405 Ok(())
406 }
407
408 fn branches(&self) -> anyhow::Result<(Vec<String>, String)> {
413 let mut names = Vec::new();
414 for branch in self.repo.branches(Some(git2::BranchType::Local))? {
415 let (b, _) = branch?;
416 if let Some(name) = b.name()? {
417 names.push(name.to_owned());
418 }
419 }
420 names.sort();
421
422 let current = self
423 .repo
424 .head()
425 .ok()
426 .and_then(|h| h.shorthand().map(|s| s.to_owned()))
427 .unwrap_or_else(|| "(detached)".to_owned());
428
429 Ok((names, current))
430 }
431
432 fn create_branch(&self, name: &str) -> anyhow::Result<()> {
433 let head = self.repo.head()?.peel_to_commit()?;
434 self.repo.branch(name, &head, false)?;
435 Ok(())
436 }
437
438 fn checkout_branch(&self, name: &str) -> anyhow::Result<()> {
439 let obj = self.repo.revparse_single(&format!("refs/heads/{}", name))?;
440 let mut checkout = git2::build::CheckoutBuilder::new();
441 checkout.safe();
442 self.repo.checkout_tree(&obj, Some(&mut checkout))?;
443 self.repo.set_head(&format!("refs/heads/{}", name))?;
444 Ok(())
445 }
446
447 fn log_commits(&self, max: usize, filter: &str) -> anyhow::Result<Vec<CommitInfo>> {
452 let mut revwalk = self.repo.revwalk()?;
453 revwalk.push_head()?;
454 revwalk.set_sorting(git2::Sort::TIME | git2::Sort::TOPOLOGICAL)?;
455
456 let filter_lower = filter.to_lowercase();
457 let mut commits = Vec::new();
458
459 for oid_result in revwalk {
460 if commits.len() >= max {
461 break;
462 }
463 let oid = oid_result?;
464 let commit = self.repo.find_commit(oid)?;
465
466 let summary = commit.summary().unwrap_or("").to_owned();
467 let author_name = commit.author().name().unwrap_or("").to_owned();
468 let oid_str = oid.to_string();
469
470 if !filter_lower.is_empty() {
471 let matches = summary.to_lowercase().contains(&filter_lower)
472 || author_name.to_lowercase().contains(&filter_lower)
473 || oid_str.contains(&filter_lower);
474 if !matches {
475 continue;
476 }
477 }
478
479 let date_relative = format_relative_time(commit.time().seconds());
480 commits.push(CommitInfo {
481 oid: oid_str,
482 summary,
483 author_name,
484 date_relative,
485 });
486 }
487
488 Ok(commits)
489 }
490
491 fn commit_files(&self, oid: &str) -> anyhow::Result<Vec<FileChange>> {
496 let oid = git2::Oid::from_str(oid)?;
497 let commit = self.repo.find_commit(oid)?;
498 let tree = commit.tree()?;
499
500 let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok());
501
502 let mut diff_opts = git2::DiffOptions::new();
503 let diff = self
504 .repo
505 .diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), Some(&mut diff_opts))?;
506
507 let mut files = Vec::new();
508 diff.foreach(
509 &mut |delta, _progress| {
510 let status = match delta.status() {
511 git2::Delta::Added | git2::Delta::Untracked => FileChangeStatus::Added,
512 git2::Delta::Deleted => FileChangeStatus::Deleted,
513 git2::Delta::Renamed | git2::Delta::Copied => FileChangeStatus::Renamed,
514 _ => FileChangeStatus::Modified,
515 };
516 let path = delta
517 .new_file()
518 .path()
519 .or_else(|| delta.old_file().path())
520 .map(PathBuf::from)
521 .unwrap_or_default();
522 files.push(FileChange { path, status });
523 true
524 },
525 None,
526 None,
527 None,
528 )?;
529
530 Ok(files)
531 }
532
533 fn commit_diff(&self, oid: &str, path: &Path) -> anyhow::Result<Vec<DiffLine>> {
538 let oid = git2::Oid::from_str(oid)?;
539 let commit = self.repo.find_commit(oid)?;
540 let tree = commit.tree()?;
541
542 let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok());
543
544 let mut diff_opts = git2::DiffOptions::new();
545 diff_opts
546 .pathspec(path.to_string_lossy().as_ref())
547 .context_lines(999_999);
548
549 let diff = self
550 .repo
551 .diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), Some(&mut diff_opts))?;
552
553 let mut lines: Vec<DiffLine> = Vec::new();
554 diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
555 let content = String::from_utf8_lossy(line.content())
556 .trim_end_matches('\n')
557 .trim_end_matches('\r')
558 .to_string();
559 match line.origin() {
560 '+' => lines.push(DiffLine {
561 line_no: line.new_lineno().map(|n| n as usize),
562 kind: DiffKind::Added,
563 content,
564 }),
565 '-' => lines.push(DiffLine {
566 line_no: line.old_lineno().map(|n| n as usize),
567 kind: DiffKind::Removed,
568 content,
569 }),
570 ' ' => lines.push(DiffLine {
571 line_no: line.new_lineno().map(|n| n as usize),
572 kind: DiffKind::Context,
573 content,
574 }),
575 'H' => lines.push(DiffLine {
576 line_no: None,
577 kind: DiffKind::HunkHeader,
578 content,
579 }),
580 _ => {}
581 }
582 true
583 })?;
584
585 Ok(lines)
586 }
587}
588
589impl GitVcs {
594 fn index_file_lines(&self, path: &Path) -> anyhow::Result<Vec<String>> {
597 let index = self.repo.index()?;
598 if let Some(entry) = index.get_path(path, 0) {
599 let blob = self.repo.find_blob(entry.id)?;
600 let text = std::str::from_utf8(blob.content())?.to_owned();
601 return Ok(split_lines(&text));
602 }
603 if let Ok(head) = self.repo.head()
605 && let Ok(tree) = head.peel_to_tree()
606 && let Ok(entry) = tree.get_path(path)
607 && let Ok(obj) = entry.to_object(&self.repo)
608 && let Some(blob) = obj.as_blob()
609 {
610 let text = std::str::from_utf8(blob.content())?.to_owned();
611 return Ok(split_lines(&text));
612 }
613 Ok(Vec::new())
614 }
615
616 fn write_index_blob(&self, path: &Path, content: &str) -> anyhow::Result<()> {
618 let oid = self.repo.blob(content.as_bytes())?;
619 let mut index = self.repo.index()?;
620
621 let mut entry = index.get_path(path, 0).unwrap_or_else(|| git2::IndexEntry {
623 ctime: git2::IndexTime::new(0, 0),
624 mtime: git2::IndexTime::new(0, 0),
625 dev: 0,
626 ino: 0,
627 mode: 0o100644,
628 uid: 0,
629 gid: 0,
630 file_size: 0,
631 id: git2::Oid::zero(),
632 flags: 0,
633 flags_extended: 0,
634 path: path.to_string_lossy().as_bytes().to_vec(),
635 });
636 entry.id = oid;
637 entry.file_size = content.len() as u32;
638
639 index.add(&entry)?;
640 index.write()?;
641 Ok(())
642 }
643}
644
645fn apply_selected_lines(
654 base_lines: &[String],
655 diff: &[DiffLine],
656 selected: &[usize],
657 apply: bool,
658) -> anyhow::Result<String> {
659 let selected_set: std::collections::HashSet<usize> = selected.iter().copied().collect();
660
661 let mut result: Vec<String> = Vec::new();
664 let mut base_pos: usize = 0;
665
666 for (i, dl) in diff.iter().enumerate() {
667 match &dl.kind {
668 DiffKind::HunkHeader => {} DiffKind::Context => {
670 if base_pos < base_lines.len() {
672 result.push(base_lines[base_pos].clone());
673 base_pos += 1;
674 }
675 }
676 DiffKind::Added => {
677 if apply && selected_set.contains(&i) {
678 result.push(dl.content.clone());
679 } else if !apply && !selected_set.contains(&i) {
680 result.push(dl.content.clone());
682 }
683 }
685 DiffKind::Removed => {
686 if apply && selected_set.contains(&i) {
687 base_pos += 1;
689 } else {
690 if base_pos < base_lines.len() {
692 result.push(base_lines[base_pos].clone());
693 base_pos += 1;
694 }
695 }
696 }
697 }
698 }
699
700 while base_pos < base_lines.len() {
702 result.push(base_lines[base_pos].clone());
703 base_pos += 1;
704 }
705
706 Ok(result.join("\n") + "\n")
707}
708
709fn split_lines(text: &str) -> Vec<String> {
710 let lines: Vec<String> = text.lines().map(|l| l.to_owned()).collect();
712 if text.ends_with('\n') && !lines.is_empty() {
714 }
716 lines
717}
718
719fn format_relative_time(unix_secs: i64) -> String {
721 let now = std::time::SystemTime::now()
722 .duration_since(std::time::UNIX_EPOCH)
723 .map(|d| d.as_secs() as i64)
724 .unwrap_or(0);
725 let delta = now.saturating_sub(unix_secs);
726 if delta < 60 {
727 "just now".to_owned()
728 } else if delta < 3_600 {
729 format!("{}m ago", delta / 60)
730 } else if delta < 86_400 {
731 format!("{}h ago", delta / 3_600)
732 } else if delta < 86_400 * 30 {
733 format!("{}d ago", delta / 86_400)
734 } else if delta < 86_400 * 365 {
735 format!("{}mo ago", delta / (86_400 * 30))
736 } else {
737 format!("{}y ago", delta / (86_400 * 365))
738 }
739}
740
741#[cfg(test)]
746mod tests {
747 use super::*;
748
749 #[test]
750 fn test_apply_selected_add() {
751 let base = vec!["line1".to_owned(), "line2".to_owned()];
752 let diff = vec![
753 DiffLine {
754 line_no: Some(1),
755 kind: DiffKind::Context,
756 content: "line1".into(),
757 },
758 DiffLine {
759 line_no: Some(2),
760 kind: DiffKind::Added,
761 content: "new".into(),
762 },
763 DiffLine {
764 line_no: Some(3),
765 kind: DiffKind::Context,
766 content: "line2".into(),
767 },
768 ];
769 let result = apply_selected_lines(&base, &diff, &[1], true).unwrap();
770 assert_eq!(result, "line1\nnew\nline2\n");
771 }
772
773 #[test]
774 fn test_apply_selected_remove() {
775 let base = vec!["line1".to_owned(), "old".to_owned(), "line2".to_owned()];
776 let diff = vec![
777 DiffLine {
778 line_no: Some(1),
779 kind: DiffKind::Context,
780 content: "line1".into(),
781 },
782 DiffLine {
783 line_no: None,
784 kind: DiffKind::Removed,
785 content: "old".into(),
786 },
787 DiffLine {
788 line_no: Some(2),
789 kind: DiffKind::Context,
790 content: "line2".into(),
791 },
792 ];
793 let result = apply_selected_lines(&base, &diff, &[1], true).unwrap();
794 assert_eq!(result, "line1\nline2\n");
795 }
796}