Skip to main content

oo_ide/
vcs.rs

1//! VCS abstraction layer.
2//!
3//! The [`Vcs`] trait is synchronous — it is always called from within
4//! `tokio::task::spawn_blocking`. [`GitVcs`] is the `git2`-backed implementation.
5
6use std::collections::HashSet;
7use std::fmt;
8use std::fs;
9use std::path::{Path, PathBuf};
10
11// ---------------------------------------------------------------------------
12// Shared data types
13// ---------------------------------------------------------------------------
14
15/// The current Git operation state (rebase, merge, or normal).
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
17pub enum GitMode {
18    #[default]
19    Normal,
20    Rebase,
21    Merge,
22}
23
24/// Snapshot of the repository's in-progress operation state, returned by
25/// [`detect_git_op_state`].  Used by the Commit View to show mode badges and
26/// gate the commit action.
27#[derive(Debug, Clone, Default)]
28pub struct GitOpState {
29    pub mode: GitMode,
30    /// True when the working tree contains at least one unresolved conflict.
31    pub has_conflicts: bool,
32    /// Pre-filled commit message (rebase step message or merge message), if any.
33    pub message: String,
34}
35
36/// Inspect the repository at `root` and return the current Git operation state.
37///
38/// Runs synchronously; call from `tokio::task::spawn_blocking`.
39pub fn detect_git_op_state(root: &Path) -> GitOpState {
40    // Determine mode from well-known state directories / files.
41    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    // Detect conflicts via git2 status flags.
51    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    // Load a pre-populated commit message for the current step.
64    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    /// `@@` hunk header — not a real file line; used as a section separator.
92    HunkHeader,
93}
94
95#[derive(Debug, Clone)]
96pub struct DiffLine {
97    /// Line number in the new (workdir) file, if applicable.
98    pub line_no: Option<usize>,
99    pub kind: DiffKind,
100    pub content: String,
101}
102
103/// A single commit summary entry used by the git history viewer.
104#[derive(Debug, Clone)]
105pub struct CommitInfo {
106    /// Full 40-character hex OID.
107    pub oid: String,
108    /// First line of the commit message, used for list display.
109    pub summary: String,
110    /// Full raw commit message (including the summary line and body).
111    pub message: String,
112    pub author_name: String,
113    pub date_relative: String,
114}
115
116/// An instruction for an interactive rebase operation.
117///
118/// Passed to [`Vcs::run_interactive_rebase`] in **newest-first** order (matching
119/// the `Vec<CommitInfo>` returned by [`Vcs::log_commits`]).
120#[derive(Debug, Clone)]
121pub enum RebaseInstruction {
122    /// Keep the commit as-is.
123    Pick { sha: String },
124    /// Reword the commit message.
125    Reword { sha: String, new_message: String },
126    /// Squash this commit into the preceding (older) commit, using the given
127    /// combined message.
128    Squash { sha: String, new_message: String },
129    /// Drop (delete) this commit.
130    Drop { sha: String },
131}
132
133/// Change status for a file in a commit.
134#[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/// A file changed within a commit.
154#[derive(Debug, Clone)]
155pub struct FileChange {
156    pub path: PathBuf,
157    pub status: FileChangeStatus,
158}
159
160// ---------------------------------------------------------------------------
161// Trait
162// ---------------------------------------------------------------------------
163
164/// Backend-agnostic VCS interface.
165///
166/// All methods are synchronous; call them from `tokio::task::spawn_blocking`.
167/// `Send` is required so the implementation can be moved into blocking tasks.
168/// `Sync` is NOT required — if an async backend is added later, wrap in
169/// `Arc<Mutex<dyn Vcs>>` at the call site.
170pub trait Vcs: Send {
171    /// Returns `(staged, unstaged)` file lists.
172    fn status(&self) -> anyhow::Result<(Vec<StatusEntry>, Vec<StatusEntry>)>;
173
174    /// Diff for a single file.
175    /// `staged = true`  → HEAD ↔ index (what would be committed).
176    /// `staged = false` → index ↔ workdir (what is not yet staged).
177    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    /// Stage only the selected lines (by index into `diff`).
183    /// Uses direct git2 index blob manipulation — no subprocess required.
184    fn stage_lines(&self, path: &Path, selected: &[usize], diff: &[DiffLine])
185    -> anyhow::Result<()>;
186
187    /// Unstage only the selected lines (revert them to HEAD content in the index).
188    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    /// Amend the HEAD commit with a new message/tree (equivalent to `git commit --amend`).
198    fn commit_amend(&self, message: &str) -> anyhow::Result<()>;
199
200    /// Return HEAD commit message and OID, if HEAD exists: (message, oid)
201    fn head_commit_message(&self) -> anyhow::Result<Option<(String, String)>>;
202
203    /// Returns `(all_branches, current_branch)`.
204    fn branches(&self) -> anyhow::Result<(Vec<String>, String)>;
205
206    /// Returns remote-tracking branch names (e.g. `"origin/main"`).
207    /// Symrefs such as `"origin/HEAD"` are excluded.
208    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    /// Return up to `max` commits reachable from HEAD, optionally filtered by
214    /// a case-insensitive substring that matches summary, author name, or OID.
215    fn log_commits(&self, max: usize, filter: &str) -> anyhow::Result<Vec<CommitInfo>>;
216
217    /// Return the list of files changed in the commit identified by the full
218    /// 40-char hex `oid`.
219    fn commit_files(&self, oid: &str) -> anyhow::Result<Vec<FileChange>>;
220
221    /// Return the diff for a single file within the commit identified by the
222    /// full 40-char hex `oid`.
223    fn commit_diff(&self, oid: &str, path: &Path) -> anyhow::Result<Vec<DiffLine>>;
224
225    /// Return the set of 0-based line indices that differ from HEAD (working
226    /// directory vs the HEAD tree).  Both staged and unstaged changes are
227    /// included — this is what git-gutter indicators should reflect.
228    fn changed_lines_vs_head(&self, path: &Path) -> anyhow::Result<HashSet<usize>>;
229
230    /// Run an interactive rebase using the provided instructions.
231    ///
232    /// `instructions` is ordered **newest-first** (same order as [`log_commits`]
233    /// output).  The method internally reverses the order for the git rebase
234    /// todo file (which requires oldest-first).
235    ///
236    /// Returns the pre-operation HEAD OID (full 40-char hex) so the caller can
237    /// push it onto an undo stack for `reset_hard`.
238    fn run_interactive_rebase(
239        &self,
240        instructions: &[RebaseInstruction],
241    ) -> anyhow::Result<String>;
242
243    /// Hard-reset HEAD to the given full OID (for undo).
244    fn reset_hard(&self, sha: &str) -> anyhow::Result<()>;
245}
246
247// ---------------------------------------------------------------------------
248// GitVcs
249// ---------------------------------------------------------------------------
250
251pub struct GitVcs {
252    /// Root path — used for resolving relative paths.
253    pub root: PathBuf,
254    repo: git2::Repository,
255}
256
257// git2::Repository doesn't implement Debug.
258impl 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    // -----------------------------------------------------------------------
278    // status
279    // -----------------------------------------------------------------------
280
281    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    // -----------------------------------------------------------------------
332    // diff_file
333    // -----------------------------------------------------------------------
334
335    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            // Include the full file as context so callers can fold at will.
340            .context_lines(999_999);
341
342        let diff = if staged {
343            // HEAD tree ↔ index
344            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            // index ↔ workdir  (include untracked handled below)
349            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                    // Hunk header — strip leading @@ prefix for display
388                    lines.push(DiffLine {
389                        line_no: None,
390                        kind: DiffKind::HunkHeader,
391                        content,
392                    });
393                }
394                _ => {} // file headers, binary markers, etc.
395            }
396            true
397        })?;
398
399        // Untracked (new) files produce no diff against the index.
400        // Read the file content directly and present it as all-Added lines.
401        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    // -----------------------------------------------------------------------
432    // stage_file / unstage_file
433    // -----------------------------------------------------------------------
434
435    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            // Deleted file — remove from index.
442            index.remove_path(path)?;
443        }
444        index.write()?;
445        Ok(())
446    }
447
448    fn unstage_file(&self, path: &Path) -> anyhow::Result<()> {
449        // Reset path in index to HEAD state.
450        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                // No HEAD (initial repo) — just remove from index.
460                let mut index = self.repo.index()?;
461                index.remove_path(path)?;
462                index.write()?;
463            }
464        }
465        Ok(())
466    }
467
468    // -----------------------------------------------------------------------
469    // stage_lines / unstage_lines — direct index blob manipulation
470    // -----------------------------------------------------------------------
471
472    fn stage_lines(
473        &self,
474        path: &Path,
475        selected: &[usize],
476        diff: &[DiffLine],
477    ) -> anyhow::Result<()> {
478        // 1. Read current index content (or HEAD content if nothing is staged).
479        let index_lines = self.index_file_lines(path)?;
480
481        // 2. Build new index content by applying selected additions/removals.
482        let new_content = apply_selected_lines(&index_lines, diff, selected, true)?;
483
484        // 3. Write blob back to index.
485        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        // Same as stage_lines but inverse — reverts selected lines to HEAD.
495        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    // -----------------------------------------------------------------------
501    // commit
502    // -----------------------------------------------------------------------
503
504    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                // Initial commit — no parent.
518                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        // Use the git CLI for amend to avoid low-level libgit2 amend complexities.
540        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    // -----------------------------------------------------------------------
555    // branches
556    // -----------------------------------------------------------------------
557
558    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                // Skip symbolic refs like "origin/HEAD"
584                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    // -----------------------------------------------------------------------
609    // log_commits
610    // -----------------------------------------------------------------------
611
612    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    // -----------------------------------------------------------------------
655    // commit_files
656    // -----------------------------------------------------------------------
657
658    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    // -----------------------------------------------------------------------
697    // commit_diff
698    // -----------------------------------------------------------------------
699
700    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        // git2::Index::get_path (called below) panics on absolute paths — guard early.
753        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); // no context — only changed lines needed
763
764        // HEAD tree → workdir (captures both staged and unstaged changes)
765        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); // 1-based → 0-based
775                }
776            true
777        })?;
778
779        // New/untracked file: HEAD tree diff returns nothing; detect via index absence.
780        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; // suppress unused warning
796                    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        // Save pre-operation HEAD sha for undo.
813        let pre_op_sha = self
814            .repo
815            .head()?
816            .peel_to_commit()
817            .map(|c| c.id().to_string())?;
818
819        // Base ref: parent of the oldest commit we're rebasing onto.
820        let base_ref = format!("HEAD~{}", instructions.len());
821
822        // Build todo file (oldest-first: instructions are newest-first, so reverse).
823        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        // Collect new messages (for reword / squash instructions).
835        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        // Create a per-repo temp directory to avoid file collisions.
845        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        // Write todo file.
855        let todo_path = tmp.join("git-rebase-todo");
856        std::fs::write(&todo_path, &todo)?;
857
858        // Write sequence-editor script: copies our todo over the git todo.
859        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        // If there are messages, write a GIT_EDITOR script that feeds them in
868        // order (one per invocation, advancing a counter via a temp state file).
869        let git_editor_path = tmp.join("git-editor.sh");
870        if !messages.is_empty() {
871            // Write message files msg_0.txt, msg_1.txt, …
872            for (i, msg) in messages.iter().enumerate() {
873                std::fs::write(tmp.join(format!("msg_{i}.txt")), msg)?;
874            }
875            // State file: tracks which message index is next.
876            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        // Clean up temp dir.
905        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
927// ---------------------------------------------------------------------------
928// GitVcs helpers
929// ---------------------------------------------------------------------------
930
931impl GitVcs {
932    /// Return the current index content for `path` as lines, falling back to
933    /// HEAD content if the path isn't yet in the index, or empty for new files.
934    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        // Try HEAD tree.
942        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    /// Write `content` as a blob into the index for `path`.
955    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        // Build an IndexEntry — copy the existing one if present, else synthesise.
960        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
983// ---------------------------------------------------------------------------
984// Line-level patch helpers
985// ---------------------------------------------------------------------------
986
987/// Apply (or reverse-apply) selected diff lines to `base_lines`.
988///
989/// `apply = true`  → stage: add/remove the selected changes into the base.
990/// `apply = false` → unstage: revert the selected changes (keep the rest).
991fn 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    // We replay the diff against `base_lines`.
1000    // base_pos tracks our position in base_lines.
1001    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 => {} // not a real content line — skip
1007            DiffKind::Context => {
1008                // Consume one line from base unchanged.
1009                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                    // Keep already-staged lines that are NOT being unstaged.
1019                    result.push(dl.content.clone());
1020                }
1021                // If not selected (stage) or selected (unstage), drop the line.
1022            }
1023            DiffKind::Removed => {
1024                if apply && selected_set.contains(&i) {
1025                    // Remove this line from base — skip consuming it.
1026                    base_pos += 1;
1027                } else {
1028                    // Keep it.
1029                    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    // Append any remaining base lines.
1039    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    // Preserve last empty line correctly.
1049    let lines: Vec<String> = text.lines().map(|l| l.to_owned()).collect();
1050    // `str::lines` drops a trailing newline; re-add empty last element if needed.
1051    if text.ends_with('\n') && !lines.is_empty() {
1052        // nothing — lines() already stripped the final newline token
1053    }
1054    lines
1055}
1056
1057/// Format a Unix timestamp as a human-readable relative time string.
1058fn 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
1079/// Set the executable bit on `path` (Unix only).
1080/// On non-Unix platforms this is a no-op.
1081fn 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// ---------------------------------------------------------------------------
1095// Tests
1096// ---------------------------------------------------------------------------
1097
1098#[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    // -----------------------------------------------------------------------
1151    // Helpers for git-integration tests
1152    // -----------------------------------------------------------------------
1153
1154    /// Create a minimal in-memory git repo in `dir`, commit `content` as `filename`,
1155    /// and return the resulting `GitVcs`.
1156    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        // Configure identity so commit doesn't fail.
1161        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        // Write file.
1167        let file_path = dir.join(filename);
1168        std::fs::write(&file_path, content).unwrap();
1169
1170        // Stage and commit in a block so the tree borrow is released before `repo` is moved.
1171        {
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        // Modify line 2 in workdir (0-based index 1) without staging.
1190        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        // Modify and STAGE line 2.
1204        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        // Append a new line.
1221        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        // No workdir changes.
1235        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        // Create a repo with a different file committed.
1243        let vcs = make_committed_repo(dir.path(), "other.txt", "x\n");
1244
1245        // Write a brand-new file that is not in HEAD at all.
1246        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}