1use std::collections::HashSet;
7use std::fmt;
8use std::fs;
9use std::path::{Path, PathBuf};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
17pub enum GitMode {
18 #[default]
19 Normal,
20 Rebase,
21 Merge,
22}
23
24#[derive(Debug, Clone, Default)]
28pub struct GitOpState {
29 pub mode: GitMode,
30 pub has_conflicts: bool,
32 pub message: String,
34}
35
36pub fn detect_git_op_state(root: &Path) -> GitOpState {
40 let git_dir = root.join(".git");
42 let mode = if git_dir.join("rebase-merge").is_dir() || git_dir.join("rebase-apply").is_dir() {
43 GitMode::Rebase
44 } else if git_dir.join("MERGE_HEAD").is_file() {
45 GitMode::Merge
46 } else {
47 GitMode::Normal
48 };
49
50 let has_conflicts = if let Ok(repo) = git2::Repository::open(root) {
52 repo.statuses(None)
53 .map(|statuses| {
54 statuses
55 .iter()
56 .any(|e| e.status().intersects(git2::Status::CONFLICTED))
57 })
58 .unwrap_or(false)
59 } else {
60 false
61 };
62
63 let message = match mode {
65 GitMode::Rebase => fs::read_to_string(git_dir.join("rebase-merge/message"))
66 .or_else(|_| fs::read_to_string(git_dir.join("COMMIT_EDITMSG")))
67 .unwrap_or_default()
68 .trim_end()
69 .to_owned(),
70 GitMode::Merge => fs::read_to_string(git_dir.join("MERGE_MSG"))
71 .unwrap_or_default()
72 .trim_end()
73 .to_owned(),
74 GitMode::Normal => String::new(),
75 };
76
77 GitOpState { mode, has_conflicts, message }
78}
79
80#[derive(Debug, Clone)]
81pub struct StatusEntry {
82 pub path: PathBuf,
83 pub staged: bool,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub enum DiffKind {
88 Context,
89 Added,
90 Removed,
91 HunkHeader,
93}
94
95#[derive(Debug, Clone)]
96pub struct DiffLine {
97 pub line_no: Option<usize>,
99 pub kind: DiffKind,
100 pub content: String,
101}
102
103#[derive(Debug, Clone)]
105pub struct CommitInfo {
106 pub oid: String,
108 pub summary: String,
110 pub message: String,
112 pub author_name: String,
113 pub date_relative: String,
114}
115
116#[derive(Debug, Clone)]
121pub enum RebaseInstruction {
122 Pick { sha: String },
124 Reword { sha: String, new_message: String },
126 Squash { sha: String, new_message: String },
129 Drop { sha: String },
131}
132
133#[derive(Debug, Clone, PartialEq, Eq)]
135pub enum FileChangeStatus {
136 Modified,
137 Added,
138 Deleted,
139 Renamed,
140}
141
142impl FileChangeStatus {
143 pub fn indicator(&self) -> &'static str {
144 match self {
145 Self::Modified => "M",
146 Self::Added => "A",
147 Self::Deleted => "D",
148 Self::Renamed => "R",
149 }
150 }
151}
152
153#[derive(Debug, Clone)]
155pub struct FileChange {
156 pub path: PathBuf,
157 pub status: FileChangeStatus,
158}
159
160pub trait Vcs: Send {
171 fn status(&self) -> anyhow::Result<(Vec<StatusEntry>, Vec<StatusEntry>)>;
173
174 fn diff_file(&self, path: &Path, staged: bool) -> anyhow::Result<Vec<DiffLine>>;
178
179 fn stage_file(&self, path: &Path) -> anyhow::Result<()>;
180 fn unstage_file(&self, path: &Path) -> anyhow::Result<()>;
181
182 fn stage_lines(&self, path: &Path, selected: &[usize], diff: &[DiffLine])
185 -> anyhow::Result<()>;
186
187 fn unstage_lines(
189 &self,
190 path: &Path,
191 selected: &[usize],
192 diff: &[DiffLine],
193 ) -> anyhow::Result<()>;
194
195 fn commit(&self, message: &str) -> anyhow::Result<()>;
196
197 fn commit_amend(&self, message: &str) -> anyhow::Result<()>;
199
200 fn head_commit_message(&self) -> anyhow::Result<Option<(String, String)>>;
202
203 fn branches(&self) -> anyhow::Result<(Vec<String>, String)>;
205
206 fn remote_branches(&self) -> anyhow::Result<Vec<String>>;
209
210 fn create_branch(&self, name: &str) -> anyhow::Result<()>;
211 fn checkout_branch(&self, name: &str) -> anyhow::Result<()>;
212
213 fn log_commits(&self, max: usize, filter: &str) -> anyhow::Result<Vec<CommitInfo>>;
216
217 fn commit_files(&self, oid: &str) -> anyhow::Result<Vec<FileChange>>;
220
221 fn commit_diff(&self, oid: &str, path: &Path) -> anyhow::Result<Vec<DiffLine>>;
224
225 fn changed_lines_vs_head(&self, path: &Path) -> anyhow::Result<HashSet<usize>>;
229
230 fn run_interactive_rebase(
239 &self,
240 instructions: &[RebaseInstruction],
241 ) -> anyhow::Result<String>;
242
243 fn reset_hard(&self, sha: &str) -> anyhow::Result<()>;
245}
246
247pub struct GitVcs {
252 pub root: PathBuf,
254 repo: git2::Repository,
255}
256
257impl fmt::Debug for GitVcs {
259 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
260 f.debug_struct("GitVcs")
261 .field("root", &self.root)
262 .finish_non_exhaustive()
263 }
264}
265
266impl GitVcs {
267 pub fn open(path: &Path) -> anyhow::Result<Self> {
268 let repo = git2::Repository::open(path)?;
269 Ok(Self {
270 root: path.to_owned(),
271 repo,
272 })
273 }
274}
275
276impl Vcs for GitVcs {
277 fn status(&self) -> anyhow::Result<(Vec<StatusEntry>, Vec<StatusEntry>)> {
282 let mut opts = git2::StatusOptions::new();
283 opts.include_untracked(true)
284 .recurse_untracked_dirs(true)
285 .include_ignored(false);
286
287 let statuses = self.repo.statuses(Some(&mut opts))?;
288
289 let mut staged = Vec::new();
290 let mut unstaged = Vec::new();
291
292 for entry in statuses.iter() {
293 let path = match entry.path() {
294 Some(p) => PathBuf::from(p),
295 None => continue,
296 };
297 let s = entry.status();
298
299 let is_staged = s.intersects(
300 git2::Status::INDEX_NEW
301 | git2::Status::INDEX_MODIFIED
302 | git2::Status::INDEX_DELETED
303 | git2::Status::INDEX_RENAMED
304 | git2::Status::INDEX_TYPECHANGE,
305 );
306 let is_unstaged = s.intersects(
307 git2::Status::WT_MODIFIED
308 | git2::Status::WT_DELETED
309 | git2::Status::WT_RENAMED
310 | git2::Status::WT_TYPECHANGE
311 | git2::Status::WT_NEW,
312 );
313
314 if is_staged {
315 staged.push(StatusEntry {
316 path: path.clone(),
317 staged: true,
318 });
319 }
320 if is_unstaged {
321 unstaged.push(StatusEntry {
322 path,
323 staged: false,
324 });
325 }
326 }
327
328 Ok((staged, unstaged))
329 }
330
331 fn diff_file(&self, path: &Path, staged: bool) -> anyhow::Result<Vec<DiffLine>> {
336 let mut diff_opts = git2::DiffOptions::new();
337 diff_opts
338 .pathspec(path.to_string_lossy().as_ref())
339 .context_lines(999_999);
341
342 let diff = if staged {
343 let head_tree = self.repo.head().ok().and_then(|h| h.peel_to_tree().ok());
345 self.repo
346 .diff_tree_to_index(head_tree.as_ref(), None, Some(&mut diff_opts))?
347 } else {
348 self.repo
350 .diff_index_to_workdir(None, Some(&mut diff_opts))?
351 };
352
353 let mut lines: Vec<DiffLine> = Vec::new();
354
355 diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
356 let content = String::from_utf8_lossy(line.content())
357 .trim_end_matches('\n')
358 .trim_end_matches('\r')
359 .to_string();
360
361 match line.origin() {
362 '+' => {
363 let line_no = line.new_lineno().map(|n| n as usize);
364 lines.push(DiffLine {
365 line_no,
366 kind: DiffKind::Added,
367 content,
368 });
369 }
370 '-' => {
371 let line_no = line.old_lineno().map(|n| n as usize);
372 lines.push(DiffLine {
373 line_no,
374 kind: DiffKind::Removed,
375 content,
376 });
377 }
378 ' ' => {
379 let line_no = line.new_lineno().map(|n| n as usize);
380 lines.push(DiffLine {
381 line_no,
382 kind: DiffKind::Context,
383 content,
384 });
385 }
386 'H' => {
387 lines.push(DiffLine {
389 line_no: None,
390 kind: DiffKind::HunkHeader,
391 content,
392 });
393 }
394 _ => {} }
396 true
397 })?;
398
399 if !staged && lines.is_empty() {
402 let abs = self.root.join(path);
403 let in_index = self
404 .repo
405 .index()
406 .ok()
407 .and_then(|idx| idx.get_path(path, 0))
408 .is_some();
409 if abs.exists()
410 && !in_index
411 && let Ok(content) = fs::read_to_string(&abs)
412 {
413 lines.push(DiffLine {
414 line_no: None,
415 kind: DiffKind::HunkHeader,
416 content: "@@ new file @@".to_string(),
417 });
418 for (i, line_content) in content.lines().enumerate() {
419 lines.push(DiffLine {
420 line_no: Some(i + 1),
421 kind: DiffKind::Added,
422 content: line_content.to_owned(),
423 });
424 }
425 }
426 }
427
428 Ok(lines)
429 }
430
431 fn stage_file(&self, path: &Path) -> anyhow::Result<()> {
436 let mut index = self.repo.index()?;
437 let abs = self.root.join(path);
438 if abs.exists() {
439 index.add_path(path)?;
440 } else {
441 index.remove_path(path)?;
443 }
444 index.write()?;
445 Ok(())
446 }
447
448 fn unstage_file(&self, path: &Path) -> anyhow::Result<()> {
449 match self.repo.head() {
451 Ok(head_ref) => {
452 let head_commit = head_ref.peel_to_commit()?;
453 let mut checkout_opts = git2::build::CheckoutBuilder::new();
454 checkout_opts.path(path).force();
455 self.repo
456 .reset_default(Some(head_commit.as_object()), [path])?;
457 }
458 Err(_) => {
459 let mut index = self.repo.index()?;
461 index.remove_path(path)?;
462 index.write()?;
463 }
464 }
465 Ok(())
466 }
467
468 fn stage_lines(
473 &self,
474 path: &Path,
475 selected: &[usize],
476 diff: &[DiffLine],
477 ) -> anyhow::Result<()> {
478 let index_lines = self.index_file_lines(path)?;
480
481 let new_content = apply_selected_lines(&index_lines, diff, selected, true)?;
483
484 self.write_index_blob(path, &new_content)
486 }
487
488 fn unstage_lines(
489 &self,
490 path: &Path,
491 selected: &[usize],
492 diff: &[DiffLine],
493 ) -> anyhow::Result<()> {
494 let index_lines = self.index_file_lines(path)?;
496 let new_content = apply_selected_lines(&index_lines, diff, selected, false)?;
497 self.write_index_blob(path, &new_content)
498 }
499
500 fn commit(&self, message: &str) -> anyhow::Result<()> {
505 let sig = self.repo.signature()?;
506 let mut index = self.repo.index()?;
507 let tree_oid = index.write_tree()?;
508 let tree = self.repo.find_tree(tree_oid)?;
509
510 match self.repo.head() {
511 Ok(head_ref) => {
512 let parent = head_ref.peel_to_commit()?;
513 self.repo
514 .commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent])?;
515 }
516 Err(_) => {
517 self.repo
519 .commit(Some("HEAD"), &sig, &sig, message, &tree, &[])?;
520 }
521 }
522 Ok(())
523 }
524
525 fn head_commit_message(&self) -> anyhow::Result<Option<(String, String)>> {
526 if let Ok(head_ref) = self.repo.head()
527 && let Some(oid) = head_ref.target() {
528 let commit = self.repo.find_commit(oid)?;
529 let msg = commit.message().unwrap_or("").to_string();
530 return Ok(Some((msg, oid.to_string())));
531 }
532 Ok(None)
533 }
534
535 fn commit_amend(&self, message: &str) -> anyhow::Result<()> {
536 use std::process::{Command, Stdio};
537 use std::io::Write;
538
539 let mut cmd = Command::new("git");
541 cmd.arg("-C").arg(self.root.to_string_lossy().to_string()).arg("commit").arg("--amend").arg("-F").arg("-").stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped());
542 let mut child = cmd.spawn()?;
543 if let Some(mut stdin) = child.stdin.take() {
544 stdin.write_all(message.as_bytes())?;
545 }
546 let out = child.wait_with_output()?;
547 if out.status.success() {
548 Ok(())
549 } else {
550 Err(anyhow::anyhow!(String::from_utf8_lossy(&out.stderr).to_string()))
551 }
552 }
553
554 fn branches(&self) -> anyhow::Result<(Vec<String>, String)> {
559 let mut names = Vec::new();
560 for branch in self.repo.branches(Some(git2::BranchType::Local))? {
561 let (b, _) = branch?;
562 if let Some(name) = b.name()? {
563 names.push(name.to_owned());
564 }
565 }
566 names.sort();
567
568 let current = self
569 .repo
570 .head()
571 .ok()
572 .and_then(|h| h.shorthand().map(|s| s.to_owned()))
573 .unwrap_or_else(|| "(detached)".to_owned());
574
575 Ok((names, current))
576 }
577
578 fn remote_branches(&self) -> anyhow::Result<Vec<String>> {
579 let mut names = Vec::new();
580 for branch in self.repo.branches(Some(git2::BranchType::Remote))? {
581 let (b, _) = branch?;
582 if let Some(name) = b.name()? {
583 if !name.ends_with("/HEAD") {
585 names.push(name.to_owned());
586 }
587 }
588 }
589 names.sort();
590 Ok(names)
591 }
592
593 fn create_branch(&self, name: &str) -> anyhow::Result<()> {
594 let head = self.repo.head()?.peel_to_commit()?;
595 self.repo.branch(name, &head, false)?;
596 Ok(())
597 }
598
599 fn checkout_branch(&self, name: &str) -> anyhow::Result<()> {
600 let obj = self.repo.revparse_single(&format!("refs/heads/{}", name))?;
601 let mut checkout = git2::build::CheckoutBuilder::new();
602 checkout.safe();
603 self.repo.checkout_tree(&obj, Some(&mut checkout))?;
604 self.repo.set_head(&format!("refs/heads/{}", name))?;
605 Ok(())
606 }
607
608 fn log_commits(&self, max: usize, filter: &str) -> anyhow::Result<Vec<CommitInfo>> {
613 let mut revwalk = self.repo.revwalk()?;
614 revwalk.push_head()?;
615 revwalk.set_sorting(git2::Sort::TIME | git2::Sort::TOPOLOGICAL)?;
616
617 let filter_lower = filter.to_lowercase();
618 let mut commits = Vec::new();
619
620 for oid_result in revwalk {
621 if commits.len() >= max {
622 break;
623 }
624 let oid = oid_result?;
625 let commit = self.repo.find_commit(oid)?;
626
627 let summary = commit.summary().unwrap_or("").to_owned();
628 let message = commit.message().unwrap_or("").to_owned();
629 let author_name = commit.author().name().unwrap_or("").to_owned();
630 let oid_str = oid.to_string();
631
632 if !filter_lower.is_empty() {
633 let matches = summary.to_lowercase().contains(&filter_lower)
634 || author_name.to_lowercase().contains(&filter_lower)
635 || oid_str.contains(&filter_lower);
636 if !matches {
637 continue;
638 }
639 }
640
641 let date_relative = format_relative_time(commit.time().seconds());
642 commits.push(CommitInfo {
643 oid: oid_str,
644 summary,
645 message,
646 author_name,
647 date_relative,
648 });
649 }
650
651 Ok(commits)
652 }
653
654 fn commit_files(&self, oid: &str) -> anyhow::Result<Vec<FileChange>> {
659 let oid = git2::Oid::from_str(oid)?;
660 let commit = self.repo.find_commit(oid)?;
661 let tree = commit.tree()?;
662
663 let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok());
664
665 let mut diff_opts = git2::DiffOptions::new();
666 let diff = self
667 .repo
668 .diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), Some(&mut diff_opts))?;
669
670 let mut files = Vec::new();
671 diff.foreach(
672 &mut |delta, _progress| {
673 let status = match delta.status() {
674 git2::Delta::Added | git2::Delta::Untracked => FileChangeStatus::Added,
675 git2::Delta::Deleted => FileChangeStatus::Deleted,
676 git2::Delta::Renamed | git2::Delta::Copied => FileChangeStatus::Renamed,
677 _ => FileChangeStatus::Modified,
678 };
679 let path = delta
680 .new_file()
681 .path()
682 .or_else(|| delta.old_file().path())
683 .map(PathBuf::from)
684 .unwrap_or_default();
685 files.push(FileChange { path, status });
686 true
687 },
688 None,
689 None,
690 None,
691 )?;
692
693 Ok(files)
694 }
695
696 fn commit_diff(&self, oid: &str, path: &Path) -> anyhow::Result<Vec<DiffLine>> {
701 let oid = git2::Oid::from_str(oid)?;
702 let commit = self.repo.find_commit(oid)?;
703 let tree = commit.tree()?;
704
705 let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok());
706
707 let mut diff_opts = git2::DiffOptions::new();
708 diff_opts
709 .pathspec(path.to_string_lossy().as_ref())
710 .context_lines(999_999);
711
712 let diff = self
713 .repo
714 .diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), Some(&mut diff_opts))?;
715
716 let mut lines: Vec<DiffLine> = Vec::new();
717 diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
718 let content = String::from_utf8_lossy(line.content())
719 .trim_end_matches('\n')
720 .trim_end_matches('\r')
721 .to_string();
722 match line.origin() {
723 '+' => lines.push(DiffLine {
724 line_no: line.new_lineno().map(|n| n as usize),
725 kind: DiffKind::Added,
726 content,
727 }),
728 '-' => lines.push(DiffLine {
729 line_no: line.old_lineno().map(|n| n as usize),
730 kind: DiffKind::Removed,
731 content,
732 }),
733 ' ' => lines.push(DiffLine {
734 line_no: line.new_lineno().map(|n| n as usize),
735 kind: DiffKind::Context,
736 content,
737 }),
738 'H' => lines.push(DiffLine {
739 line_no: None,
740 kind: DiffKind::HunkHeader,
741 content,
742 }),
743 _ => {}
744 }
745 true
746 })?;
747
748 Ok(lines)
749 }
750
751 fn changed_lines_vs_head(&self, path: &Path) -> anyhow::Result<HashSet<usize>> {
752 anyhow::ensure!(
754 path.is_relative(),
755 "changed_lines_vs_head requires a repo-relative path, got: {}",
756 path.display()
757 );
758
759 let mut diff_opts = git2::DiffOptions::new();
760 diff_opts
761 .pathspec(path.to_string_lossy().as_ref())
762 .context_lines(0); let head_tree = self.repo.head().ok().and_then(|h| h.peel_to_tree().ok());
766 let diff = self
767 .repo
768 .diff_tree_to_workdir(head_tree.as_ref(), Some(&mut diff_opts))?;
769
770 let mut changed: HashSet<usize> = HashSet::new();
771 diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
772 if line.origin() == '+'
773 && let Some(n) = line.new_lineno() {
774 changed.insert(n.saturating_sub(1) as usize); }
776 true
777 })?;
778
779 if changed.is_empty() {
781 let abs = self.root.join(path);
782 let in_index = self
783 .repo
784 .index()
785 .ok()
786 .and_then(|idx| idx.get_path(path, 0))
787 .is_some();
788 let has_head = head_tree.is_some()
789 && head_tree
790 .as_ref()
791 .and_then(|t| t.get_path(path).ok())
792 .is_some();
793 if !has_head && abs.exists()
794 && let Ok(content) = fs::read_to_string(&abs) {
795 let _ = in_index; for i in 0..content.lines().count() {
797 changed.insert(i);
798 }
799 }
800 }
801
802 Ok(changed)
803 }
804
805 fn run_interactive_rebase(
806 &self,
807 instructions: &[RebaseInstruction],
808 ) -> anyhow::Result<String> {
809 use std::io::Write as _;
810 use std::process::{Command, Stdio};
811
812 let pre_op_sha = self
814 .repo
815 .head()?
816 .peel_to_commit()
817 .map(|c| c.id().to_string())?;
818
819 let base_ref = format!("HEAD~{}", instructions.len());
821
822 let mut todo = String::new();
824 for instr in instructions.iter().rev() {
825 let line = match instr {
826 RebaseInstruction::Pick { sha } => format!("pick {sha}\n"),
827 RebaseInstruction::Reword { sha, .. } => format!("reword {sha}\n"),
828 RebaseInstruction::Squash { sha, .. } => format!("squash {sha}\n"),
829 RebaseInstruction::Drop { sha } => format!("drop {sha}\n"),
830 };
831 todo.push_str(&line);
832 }
833
834 let messages: Vec<&str> = instructions
836 .iter()
837 .filter_map(|i| match i {
838 RebaseInstruction::Reword { new_message, .. }
839 | RebaseInstruction::Squash { new_message, .. } => Some(new_message.as_str()),
840 _ => None,
841 })
842 .collect();
843
844 let repo_hash = {
846 use std::hash::{Hash, Hasher};
847 let mut h = std::collections::hash_map::DefaultHasher::new();
848 self.root.hash(&mut h);
849 h.finish()
850 };
851 let tmp = std::env::temp_dir().join(format!("oo_rebase_{repo_hash:x}"));
852 std::fs::create_dir_all(&tmp)?;
853
854 let todo_path = tmp.join("git-rebase-todo");
856 std::fs::write(&todo_path, &todo)?;
857
858 let seq_editor_path = tmp.join("seq-editor.sh");
860 {
861 let mut f = std::fs::File::create(&seq_editor_path)?;
862 writeln!(f, "#!/bin/sh")?;
863 writeln!(f, "cp '{}' \"$1\"", todo_path.display())?;
864 }
865 set_executable(&seq_editor_path)?;
866
867 let git_editor_path = tmp.join("git-editor.sh");
870 if !messages.is_empty() {
871 for (i, msg) in messages.iter().enumerate() {
873 std::fs::write(tmp.join(format!("msg_{i}.txt")), msg)?;
874 }
875 std::fs::write(tmp.join("msg_idx.txt"), "0")?;
877 let mut f = std::fs::File::create(&git_editor_path)?;
878 writeln!(f, "#!/bin/sh")?;
879 writeln!(f, "IDX_FILE='{}'", tmp.join("msg_idx.txt").display())?;
880 writeln!(f, "IDX=$(cat \"$IDX_FILE\")")?;
881 writeln!(f, "MSG_FILE='{}/msg_'\"$IDX\"'.txt'", tmp.display())?;
882 writeln!(f, "cp \"$MSG_FILE\" \"$1\"")?;
883 writeln!(f, "echo $((IDX + 1)) > \"$IDX_FILE\"")?;
884 } else {
885 let mut f = std::fs::File::create(&git_editor_path)?;
886 writeln!(f, "#!/bin/sh")?;
887 writeln!(f, "true")?;
888 }
889 set_executable(&git_editor_path)?;
890
891 let out = Command::new("git")
892 .arg("-C")
893 .arg(self.root.to_string_lossy().to_string())
894 .arg("rebase")
895 .arg("-i")
896 .arg("--no-autosquash")
897 .arg(&base_ref)
898 .env("GIT_SEQUENCE_EDITOR", seq_editor_path.to_string_lossy().to_string())
899 .env("GIT_EDITOR", git_editor_path.to_string_lossy().to_string())
900 .stdout(Stdio::piped())
901 .stderr(Stdio::piped())
902 .output()?;
903
904 let _ = std::fs::remove_dir_all(&tmp);
906
907 if out.status.success() {
908 Ok(pre_op_sha)
909 } else {
910 let stderr = String::from_utf8_lossy(&out.stderr);
911 let stdout = String::from_utf8_lossy(&out.stdout);
912 Err(anyhow::anyhow!("{}{}", stdout, stderr))
913 }
914 }
915
916 fn reset_hard(&self, sha: &str) -> anyhow::Result<()> {
917 let oid = git2::Oid::from_str(sha)?;
918 let commit = self.repo.find_commit(oid)?;
919 let mut checkout = git2::build::CheckoutBuilder::new();
920 checkout.force();
921 self.repo
922 .reset(commit.as_object(), git2::ResetType::Hard, Some(&mut checkout))?;
923 Ok(())
924 }
925}
926
927impl GitVcs {
932 fn index_file_lines(&self, path: &Path) -> anyhow::Result<Vec<String>> {
935 let index = self.repo.index()?;
936 if let Some(entry) = index.get_path(path, 0) {
937 let blob = self.repo.find_blob(entry.id)?;
938 let text = std::str::from_utf8(blob.content())?.to_owned();
939 return Ok(split_lines(&text));
940 }
941 if let Ok(head) = self.repo.head()
943 && let Ok(tree) = head.peel_to_tree()
944 && let Ok(entry) = tree.get_path(path)
945 && let Ok(obj) = entry.to_object(&self.repo)
946 && let Some(blob) = obj.as_blob()
947 {
948 let text = std::str::from_utf8(blob.content())?.to_owned();
949 return Ok(split_lines(&text));
950 }
951 Ok(Vec::new())
952 }
953
954 fn write_index_blob(&self, path: &Path, content: &str) -> anyhow::Result<()> {
956 let oid = self.repo.blob(content.as_bytes())?;
957 let mut index = self.repo.index()?;
958
959 let mut entry = index.get_path(path, 0).unwrap_or_else(|| git2::IndexEntry {
961 ctime: git2::IndexTime::new(0, 0),
962 mtime: git2::IndexTime::new(0, 0),
963 dev: 0,
964 ino: 0,
965 mode: 0o100644,
966 uid: 0,
967 gid: 0,
968 file_size: 0,
969 id: git2::Oid::zero(),
970 flags: 0,
971 flags_extended: 0,
972 path: path.to_string_lossy().as_bytes().to_vec(),
973 });
974 entry.id = oid;
975 entry.file_size = content.len() as u32;
976
977 index.add(&entry)?;
978 index.write()?;
979 Ok(())
980 }
981}
982
983fn apply_selected_lines(
992 base_lines: &[String],
993 diff: &[DiffLine],
994 selected: &[usize],
995 apply: bool,
996) -> anyhow::Result<String> {
997 let selected_set: std::collections::HashSet<usize> = selected.iter().copied().collect();
998
999 let mut result: Vec<String> = Vec::new();
1002 let mut base_pos: usize = 0;
1003
1004 for (i, dl) in diff.iter().enumerate() {
1005 match &dl.kind {
1006 DiffKind::HunkHeader => {} DiffKind::Context => {
1008 if base_pos < base_lines.len() {
1010 result.push(base_lines[base_pos].clone());
1011 base_pos += 1;
1012 }
1013 }
1014 DiffKind::Added => {
1015 if apply && selected_set.contains(&i) {
1016 result.push(dl.content.clone());
1017 } else if !apply && !selected_set.contains(&i) {
1018 result.push(dl.content.clone());
1020 }
1021 }
1023 DiffKind::Removed => {
1024 if apply && selected_set.contains(&i) {
1025 base_pos += 1;
1027 } else {
1028 if base_pos < base_lines.len() {
1030 result.push(base_lines[base_pos].clone());
1031 base_pos += 1;
1032 }
1033 }
1034 }
1035 }
1036 }
1037
1038 while base_pos < base_lines.len() {
1040 result.push(base_lines[base_pos].clone());
1041 base_pos += 1;
1042 }
1043
1044 Ok(result.join("\n") + "\n")
1045}
1046
1047fn split_lines(text: &str) -> Vec<String> {
1048 let lines: Vec<String> = text.lines().map(|l| l.to_owned()).collect();
1050 if text.ends_with('\n') && !lines.is_empty() {
1052 }
1054 lines
1055}
1056
1057fn format_relative_time(unix_secs: i64) -> String {
1059 let now = std::time::SystemTime::now()
1060 .duration_since(std::time::UNIX_EPOCH)
1061 .map(|d| d.as_secs() as i64)
1062 .unwrap_or(0);
1063 let delta = now.saturating_sub(unix_secs);
1064 if delta < 60 {
1065 "just now".to_owned()
1066 } else if delta < 3_600 {
1067 format!("{}m ago", delta / 60)
1068 } else if delta < 86_400 {
1069 format!("{}h ago", delta / 3_600)
1070 } else if delta < 86_400 * 30 {
1071 format!("{}d ago", delta / 86_400)
1072 } else if delta < 86_400 * 365 {
1073 format!("{}mo ago", delta / (86_400 * 30))
1074 } else {
1075 format!("{}y ago", delta / (86_400 * 365))
1076 }
1077}
1078
1079fn set_executable(path: &std::path::Path) -> std::io::Result<()> {
1082 #[cfg(unix)]
1083 {
1084 use std::os::unix::fs::PermissionsExt as _;
1085 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755))?;
1086 }
1087 #[cfg(not(unix))]
1088 {
1089 let _ = path;
1090 }
1091 Ok(())
1092}
1093
1094#[cfg(test)]
1099mod tests {
1100 use super::*;
1101
1102 #[test]
1103 fn test_apply_selected_add() {
1104 let base = vec!["line1".to_owned(), "line2".to_owned()];
1105 let diff = vec![
1106 DiffLine {
1107 line_no: Some(1),
1108 kind: DiffKind::Context,
1109 content: "line1".into(),
1110 },
1111 DiffLine {
1112 line_no: Some(2),
1113 kind: DiffKind::Added,
1114 content: "new".into(),
1115 },
1116 DiffLine {
1117 line_no: Some(3),
1118 kind: DiffKind::Context,
1119 content: "line2".into(),
1120 },
1121 ];
1122 let result = apply_selected_lines(&base, &diff, &[1], true).unwrap();
1123 assert_eq!(result, "line1\nnew\nline2\n");
1124 }
1125
1126 #[test]
1127 fn test_apply_selected_remove() {
1128 let base = vec!["line1".to_owned(), "old".to_owned(), "line2".to_owned()];
1129 let diff = vec![
1130 DiffLine {
1131 line_no: Some(1),
1132 kind: DiffKind::Context,
1133 content: "line1".into(),
1134 },
1135 DiffLine {
1136 line_no: None,
1137 kind: DiffKind::Removed,
1138 content: "old".into(),
1139 },
1140 DiffLine {
1141 line_no: Some(2),
1142 kind: DiffKind::Context,
1143 content: "line2".into(),
1144 },
1145 ];
1146 let result = apply_selected_lines(&base, &diff, &[1], true).unwrap();
1147 assert_eq!(result, "line1\nline2\n");
1148 }
1149
1150 fn make_committed_repo(dir: &std::path::Path, filename: &str, content: &str) -> GitVcs {
1157 use git2::{Repository, Signature};
1158 let repo = Repository::init(dir).unwrap();
1159
1160 let mut cfg = repo.config().unwrap();
1162 cfg.set_str("user.name", "Test").unwrap();
1163 cfg.set_str("user.email", "test@example.com").unwrap();
1164 drop(cfg);
1165
1166 let file_path = dir.join(filename);
1168 std::fs::write(&file_path, content).unwrap();
1169
1170 {
1172 let mut index = repo.index().unwrap();
1173 index.add_path(std::path::Path::new(filename)).unwrap();
1174 index.write().unwrap();
1175 let tree_oid = index.write_tree().unwrap();
1176 let tree = repo.find_tree(tree_oid).unwrap();
1177 let sig = Signature::now("Test", "test@example.com").unwrap();
1178 repo.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[]).unwrap();
1179 }
1180
1181 GitVcs { root: dir.to_path_buf(), repo }
1182 }
1183
1184 #[test]
1185 fn changed_lines_vs_head_detects_unstaged_modification() {
1186 let dir = tempfile::tempdir().unwrap();
1187 let vcs = make_committed_repo(dir.path(), "file.txt", "line1\nline2\nline3\n");
1188
1189 std::fs::write(dir.path().join("file.txt"), "line1\nMODIFIED\nline3\n").unwrap();
1191
1192 let changed = vcs.changed_lines_vs_head(std::path::Path::new("file.txt")).unwrap();
1193 assert!(changed.contains(&1), "line 2 (0-based 1) should be marked changed; got {changed:?}");
1194 assert!(!changed.contains(&0), "line 1 unchanged");
1195 assert!(!changed.contains(&2), "line 3 unchanged");
1196 }
1197
1198 #[test]
1199 fn changed_lines_vs_head_detects_staged_modification() {
1200 let dir = tempfile::tempdir().unwrap();
1201 let vcs = make_committed_repo(dir.path(), "file.txt", "line1\nline2\nline3\n");
1202
1203 std::fs::write(dir.path().join("file.txt"), "line1\nSTAGED\nline3\n").unwrap();
1205 let mut index = vcs.repo.index().unwrap();
1206 index.add_path(std::path::Path::new("file.txt")).unwrap();
1207 index.write().unwrap();
1208
1209 let changed = vcs.changed_lines_vs_head(std::path::Path::new("file.txt")).unwrap();
1210 assert!(changed.contains(&1), "staged modification should appear; got {changed:?}");
1211 assert!(!changed.contains(&0));
1212 assert!(!changed.contains(&2));
1213 }
1214
1215 #[test]
1216 fn changed_lines_vs_head_detects_added_lines() {
1217 let dir = tempfile::tempdir().unwrap();
1218 let vcs = make_committed_repo(dir.path(), "file.txt", "line1\nline2\n");
1219
1220 std::fs::write(dir.path().join("file.txt"), "line1\nline2\nnew_line\n").unwrap();
1222
1223 let changed = vcs.changed_lines_vs_head(std::path::Path::new("file.txt")).unwrap();
1224 assert!(changed.contains(&2), "newly added line (0-based 2) should be marked; got {changed:?}");
1225 assert!(!changed.contains(&0));
1226 assert!(!changed.contains(&1));
1227 }
1228
1229 #[test]
1230 fn changed_lines_vs_head_empty_for_unchanged_file() {
1231 let dir = tempfile::tempdir().unwrap();
1232 let vcs = make_committed_repo(dir.path(), "file.txt", "line1\nline2\n");
1233
1234 let changed = vcs.changed_lines_vs_head(std::path::Path::new("file.txt")).unwrap();
1236 assert!(changed.is_empty(), "no changes → empty set; got {changed:?}");
1237 }
1238
1239 #[test]
1240 fn changed_lines_vs_head_new_untracked_file_all_lines() {
1241 let dir = tempfile::tempdir().unwrap();
1242 let vcs = make_committed_repo(dir.path(), "other.txt", "x\n");
1244
1245 std::fs::write(dir.path().join("new.txt"), "a\nb\nc\n").unwrap();
1247
1248 let changed = vcs.changed_lines_vs_head(std::path::Path::new("new.txt")).unwrap();
1249 assert_eq!(changed.len(), 3, "all 3 lines of new file should be marked; got {changed:?}");
1250 assert!(changed.contains(&0));
1251 assert!(changed.contains(&1));
1252 assert!(changed.contains(&2));
1253 }
1254}