Skip to main content

gitwig_core/
lib.rs

1//! Filesystem + git repository inspection.
2//!
3//! This module owns both the "is this a git repo?" classification used by
4//! the per-card indicator AND the richer detail collection used by the
5//! Detail view. They share a single `collect_summary` helper so the same
6//! libgit2 work doesn't run twice.
7//!
8//! The cheap `is_dir()` + `.git`-existence check still gates everything —
9//! we only spin up libgit2 when both checks pass.
10
11#![deny(unsafe_code)]
12#![deny(unused_imports, unused_must_use, dead_code, unused_assignments)]
13#![deny(clippy::all, clippy::perf)]
14#![allow(clippy::collapsible_if, clippy::collapsible_else_if)]
15#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::panic))]
16
17use std::path::{Path, PathBuf};
18use std::time::{Duration, SystemTime, UNIX_EPOCH};
19
20use git2::{Repository, StatusOptions, StatusShow};
21
22// ── Card-level status ──────────────────────────────────────────────────────
23
24/// Per-item filesystem classification carried alongside `config.items`.
25/// `GitRepo`'s inner `Option` is `None` when `.git` exists but libgit2
26/// couldn't open or read the repo — we know it's a repo, we just can't
27/// summarize its state.
28#[derive(Debug, Clone)]
29pub enum ItemStatus {
30    Missing,
31    Directory,
32    GitRepo(Option<RepoSummary>),
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
36pub enum RepoState {
37    #[default]
38    Clean,
39    Merge,
40    Revert,
41    CherryPick,
42    Bisect,
43    Rebase,
44    ApplyMailbox,
45}
46
47/// Compact summary used to draw the per-card indicator. Also embedded in
48/// `RepoInfo` so the Detail view doesn't re-collect the same data.
49#[derive(Debug, Default, Clone)]
50pub struct RepoSummary {
51    /// Current branch shorthand (e.g. `"main"`). `None` for detached HEAD
52    /// or when the ref cannot be read.
53    pub branch: Option<String>,
54    pub staged: usize,
55    pub modified: usize,
56    pub untracked: usize,
57    pub conflicted: usize,
58    pub ahead: usize,
59    pub behind: usize,
60    pub state: RepoState,
61    pub last_commit_time: Option<i64>,
62}
63
64impl RepoSummary {
65    pub fn is_clean(&self) -> bool {
66        self.staged + self.modified + self.untracked + self.conflicted == 0
67    }
68    pub fn is_synced(&self) -> bool {
69        self.ahead + self.behind == 0
70    }
71    pub fn unchanged(&self) -> bool {
72        self.is_clean() && self.is_synced()
73    }
74}
75
76// ── Detail view ────────────────────────────────────────────────────────────
77
78#[derive(Debug, Clone)]
79pub enum ItemDetail {
80    Missing { resolved: PathBuf },
81    Directory { resolved: PathBuf },
82    Repo { resolved: PathBuf, info: Box<RepoInfo> },
83    Error { resolved: PathBuf, message: String },
84}
85
86#[derive(Debug, Clone, Default)]
87pub struct BranchInfo {
88    pub name: String,
89    pub is_head: bool,
90    pub short_sha: String,
91    pub short_message: String,
92}
93
94#[derive(Debug, Clone, Default)]
95pub struct FileRevision {
96    pub commit_oid: String,
97    pub author: String,
98    pub date: String,
99    pub when: String,
100    pub summary: String,
101}
102
103#[derive(Debug, Clone, Default)]
104pub struct StashInfo {
105    pub index: usize,
106    pub message: String,
107    pub commit_id: String,
108    pub files: Vec<FileEntry>,
109}
110
111#[derive(Debug, Clone, Default)]
112pub struct CommitterStat {
113    pub name: String,
114    pub email: String,
115    pub count: usize,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq, Default)]
119pub enum TabData<T> {
120    #[default]
121    NotLoaded,
122    Loading,
123    Loaded(T),
124    Error(String),
125}
126
127impl<T> TabData<T> {
128    pub fn is_not_loaded(&self) -> bool {
129        matches!(self, TabData::NotLoaded)
130    }
131    pub fn is_loading(&self) -> bool {
132        matches!(self, TabData::Loading)
133    }
134    #[allow(dead_code)]
135    pub fn is_loaded(&self) -> bool {
136        matches!(self, TabData::Loaded(_))
137    }
138    pub fn as_ref(&self) -> Option<&T> {
139        match self {
140            TabData::Loaded(val) => Some(val),
141            _ => None,
142        }
143    }
144}
145
146impl<T> TabData<Vec<T>> {
147    pub fn len(&self) -> usize {
148        self.as_ref().map(|v| v.len()).unwrap_or(0)
149    }
150    pub fn is_empty(&self) -> bool {
151        self.as_ref().map(|v| v.is_empty()).unwrap_or(true)
152    }
153    pub fn first(&self) -> Option<&T> {
154        self.as_ref().and_then(|v| v.first())
155    }
156    pub fn get(&self, index: usize) -> Option<&T> {
157        self.as_ref().and_then(|v| v.get(index))
158    }
159    pub fn iter(&self) -> std::slice::Iter<'_, T> {
160        match self {
161            TabData::Loaded(v) => v.iter(),
162            _ => [].iter(),
163        }
164    }
165    pub fn as_slice(&self) -> &[T] {
166        match self {
167            TabData::Loaded(v) => v.as_slice(),
168            _ => &[],
169        }
170    }
171}
172
173#[derive(Debug, Clone, Default, PartialEq, Eq)]
174pub struct WorktreeInfo {
175    pub name: String,
176    pub path: PathBuf,
177    pub branch: Option<String>,
178    pub is_locked: bool,
179    pub lock_reason: Option<String>,
180}
181
182#[derive(Debug, Clone, Default, PartialEq, Eq)]
183pub struct SubmoduleInfo {
184    pub name: String,
185    pub path: PathBuf,
186    pub url: String,
187    pub commit_id: Option<String>,
188    pub head_id: Option<String>,
189    pub is_initialized: bool,
190    pub is_dirty: bool,
191}
192
193#[derive(Debug, Clone)]
194pub enum TabPayload {
195    Files(Result<Vec<String>, String>),
196    Graph(Result<Vec<GraphLine>, String>),
197    Branches { local: Result<Vec<BranchInfo>, String>, remote: Result<Vec<BranchInfo>, String> },
198    Tags { local: Result<Vec<BranchInfo>, String>, remote: Result<Vec<BranchInfo>, String> },
199    Remotes(Result<Vec<RemoteInfo>, String>),
200    Stashes(Result<Vec<StashInfo>, String>),
201    Overview(Result<(Vec<CommitterStat>, bool), String>),
202    Worktrees(Result<Vec<WorktreeInfo>, String>),
203    Submodules(Result<Vec<SubmoduleInfo>, String>),
204    Reflog(Result<Vec<ReflogEntry>, String>),
205}
206
207#[derive(Debug, Default, Clone)]
208pub struct RepoInfo {
209    pub branch: Option<String>,
210    pub head: Option<HeadInfo>,
211    pub remotes: TabData<Vec<RemoteInfo>>,
212    /// Configured upstream branch (e.g. "origin/main") if HEAD tracks one.
213    pub upstream: Option<String>,
214    pub summary: RepoSummary,
215    /// File-level changes, populated by `collect_info` for the Detail view.
216    pub changes: WorktreeChanges,
217    /// Recent commits in this repository.
218    pub commits: Vec<CommitEntry>,
219    /// Graph view lines for the repository.
220    pub graph_lines: TabData<Vec<GraphLine>>,
221    /// Local branches in the repository.
222    pub local_branches: TabData<Vec<BranchInfo>>,
223    /// Remote branches in the repository.
224    pub remote_branches: TabData<Vec<BranchInfo>>,
225    /// Local tags in the repository.
226    pub local_tags: TabData<Vec<BranchInfo>>,
227    /// Remote tags in the repository.
228    pub remote_tags: TabData<Vec<BranchInfo>>,
229    /// Whether remote tags have been loaded from the remote repository.
230    pub remote_tags_loaded: bool,
231    /// Whether a remote tag fetch has been attempted in this session.
232    pub remote_tags_attempted: bool,
233    /// Tracked files in the repository.
234    pub files: TabData<Vec<String>>,
235    /// Available stashes in the repository.
236    pub stashes: TabData<Vec<StashInfo>>,
237    /// Available worktrees in the repository.
238    pub worktrees: TabData<Vec<WorktreeInfo>>,
239    /// Configured submodules in the repository.
240    pub submodules: TabData<Vec<SubmoduleInfo>>,
241    /// Available reflog entries.
242    pub reflog: TabData<Vec<ReflogEntry>>,
243    /// Committer statistics.
244    pub committer_stats: TabData<Vec<CommitterStat>>,
245    /// Whether the committer statistics walk was capped by the limit.
246    pub committer_stats_limit_reached: bool,
247    /// Timestamps when each tab was loaded (index matches tab_idx)
248    pub tab_loaded_at: [Option<std::time::Instant>; 10],
249    /// Whether each tab is currently loading in the background (index matches tab_idx)
250    pub tab_loading: [bool; 10],
251}
252
253#[derive(Debug, Clone)]
254pub struct HeadInfo {
255    pub short_id: String,
256    pub summary: String,
257    pub author: String,
258    pub when: String,
259}
260
261#[derive(Debug, Clone)]
262pub struct ReflogEntry {
263    pub index: usize,
264    pub target_oid: String,
265    pub selector: String,
266    pub command: String,
267    pub message: String,
268    pub when: String,
269    pub date: String,
270}
271
272#[derive(Debug, Clone)]
273pub struct RemoteInfo {
274    pub name: String,
275    pub url: String,
276    pub push_url: Option<String>,
277    pub refspecs: Vec<String>,
278}
279
280#[derive(Debug, Clone)]
281pub struct CommitEntry {
282    /// Short 7-char display ID.
283    pub id: String,
284    /// Full 40-char hex OID — used for diff lookup.
285    pub oid: String,
286    pub author: String,
287    pub when: String,
288    pub date: String,
289    pub summary: String,
290    pub message: String,
291    /// Local branch names and tags pointing at this commit.
292    /// Tags are prefixed with `"tag:"`, remote branches with `"remote:"`.
293    pub refs: Vec<String>,
294    /// Files changed in this commit (diff against first parent, or empty tree).
295    pub files: Vec<FileEntry>,
296    /// GPG/SSH signature status.
297    pub signature_status: String,
298}
299
300#[derive(Debug, Clone)]
301pub struct GraphLine {
302    pub graph: String,
303    pub commit: Option<GraphCommit>,
304}
305
306#[derive(Debug, Clone)]
307pub struct GraphCommit {
308    pub oid: String,
309    pub decoration: String,
310    pub summary: String,
311    pub author: String,
312    pub date: String,
313    /// GPG/SSH signature status.
314    pub signature_status: String,
315}
316
317/// One changed file in the working tree or index.
318#[derive(Debug, Clone)]
319pub struct FileEntry {
320    /// Path relative to the repository root.
321    pub path: String,
322    /// Short human-readable label: "N", "M", "D", "R", "T", "?", or "C".
323    pub label: &'static str,
324}
325
326/// File-level working-tree state collected for the Detail view.
327/// Split into four buckets so the UI can render them as separate sections.
328#[derive(Debug, Default, Clone)]
329pub struct WorktreeChanges {
330    pub staged: Vec<FileEntry>,
331    pub unstaged: Vec<FileEntry>,
332    pub untracked: Vec<FileEntry>,
333    pub conflicted: Vec<FileEntry>,
334}
335
336// ── Per-file diff ──────────────────────────────────────────────────────────
337
338/// The type of a single line in a unified diff.
339#[derive(Debug, Clone, PartialEq)]
340pub enum DiffLineKind {
341    /// `@@ ... @@` hunk header.
342    Header,
343    /// `+` added line.
344    Added,
345    /// `-` removed line.
346    Removed,
347    /// Unchanged context line.
348    Context,
349    /// Line in OURS section of a conflict.
350    ConflictOurs,
351    /// Line in THEIRS section of a conflict.
352    ConflictTheirs,
353    /// Conflict marker line (<<<<<<<, =======, >>>>>>>).
354    ConflictSeparator,
355}
356
357/// One line of a unified diff, as rendered in the Diff panel.
358#[derive(Debug, Clone)]
359pub struct DiffLine {
360    pub kind: DiffLineKind,
361    /// Raw content (already includes the leading +/−/space prefix character).
362    pub content: String,
363}
364
365/// Return the unified diff of `file_path` as it changed in `commit_oid`
366/// (hex string) inside the repository at `repo_path`.
367/// Returns an empty Vec on any error.
368pub fn get_commit_file_diff(repo_path: &Path, commit_oid: &str, file_path: &str) -> Vec<DiffLine> {
369    get_file_diff_inner(repo_path, commit_oid, file_path).unwrap_or_default()
370}
371
372/// Return the diff for `file_path` in the working tree.
373///
374/// - `staged = true`:  diff between HEAD and the index (what would be committed).
375/// - `staged = false`: diff between the index and the working directory (unstaged changes).
376///
377/// Returns an empty Vec on any error.
378pub fn get_worktree_file_diff(repo_path: &Path, file_path: &str, staged: bool) -> Vec<DiffLine> {
379    get_worktree_diff_inner(repo_path, file_path, staged).unwrap_or_default()
380}
381
382/// Add `file_path` to the index (equivalent to `git add <file>`).
383/// Returns a human-readable error string on failure.
384pub fn stage_file(repo_path: &Path, file_path: &str) -> Result<(), String> {
385    let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
386    let mut index = repo.index().map_err(|e| e.to_string())?;
387    let full_path = repo_path.join(file_path);
388    if full_path.exists() {
389        index.add_path(Path::new(file_path)).map_err(|e| e.to_string())?;
390    } else {
391        index.remove_path(Path::new(file_path)).map_err(|e| e.to_string())?;
392    }
393    index.write().map_err(|e| e.to_string())?;
394    Ok(())
395}
396
397/// Remove `file_path` from the index (equivalent to `git restore --staged <file>`).
398/// When HEAD exists the index entry is reset to the HEAD tree value; for a brand-new
399/// repo with no commits the entry is simply removed from the index.
400/// Returns a human-readable error string on failure.
401pub fn unstage_file(repo_path: &Path, file_path: &str) -> Result<(), String> {
402    let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
403    // Prefer reset_default (git reset HEAD -- <file>) when a HEAD commit exists.
404    if let Some(commit) = repo.head().ok().and_then(|h| h.peel_to_commit().ok()) {
405        repo.reset_default(Some(commit.as_object()), std::iter::once(file_path))
406            .map_err(|e| e.to_string())?;
407    } else {
408        // New repo with no commits: just remove the entry from the index.
409        let mut index = repo.index().map_err(|e| e.to_string())?;
410        index.remove_path(Path::new(file_path)).map_err(|e| e.to_string())?;
411        index.write().map_err(|e| e.to_string())?;
412    }
413    Ok(())
414}
415
416pub fn sanitize_text(s: &str) -> String {
417    let mut result = String::with_capacity(s.len());
418    let bytes = s.as_bytes();
419    let mut i = 0;
420    while i < bytes.len() {
421        if bytes[i] == 0x1B {
422            // Escape character: let's skip the sequence
423            i += 1;
424            if i < bytes.len() && bytes[i] == b'[' {
425                i += 1;
426                while i < bytes.len() {
427                    let c = bytes[i];
428                    i += 1;
429                    // ANSI escape sequences end with letters (A-Z, a-z)
430                    if c.is_ascii_alphabetic() {
431                        break;
432                    }
433                }
434            } else {
435                // Single ESC character or other VT100: skip it
436                while i < bytes.len() {
437                    let c = bytes[i];
438                    i += 1;
439                    if c == b' ' || c.is_ascii_alphabetic() {
440                        break;
441                    }
442                }
443            }
444        } else {
445            let c = bytes[i];
446            // Replace non-printable control characters except \n, \r, \t
447            if c < 0x20 && c != b'\n' && c != b'\r' && c != b'\t' {
448                result.push(' ');
449            } else {
450                result.push(c as char);
451            }
452            i += 1;
453        }
454    }
455    result
456}
457
458pub fn safe_sha_slice(sha: &str, len: usize) -> &str {
459    let mut end = len.min(sha.len());
460    while end > 0 && !sha.is_char_boundary(end) {
461        end -= 1;
462    }
463    &sha[..end]
464}
465
466fn ssh_command_val() -> &'static str {
467    if std::env::var("GITWIG_SSH_STRICT").map(|v| v == "1").unwrap_or(false) {
468        "ssh -o StrictHostKeyChecking=yes"
469    } else {
470        "ssh -o StrictHostKeyChecking=accept-new"
471    }
472}
473
474fn git_command() -> std::process::Command {
475    let mut cmd = std::process::Command::new("git");
476    cmd.env("GIT_TERMINAL_PROMPT", "0");
477    cmd.env("GIT_SSH_COMMAND", ssh_command_val());
478    cmd.env("GIT_ALLOW_PROTOCOL", "https:ssh:git:file");
479    cmd.env("GIT_PROTOCOL_FROM_USER", "0");
480    cmd
481}
482
483pub fn safe_ref(r: &str) -> Result<&str, String> {
484    let trimmed = r.trim();
485    if trimmed.starts_with('-') {
486        return Err(format!("Invalid ref name: '{}' (ref names cannot start with '-')", r));
487    }
488    if trimmed.is_empty() {
489        return Err("Ref name cannot be empty".to_string());
490    }
491    Ok(trimmed)
492}
493
494/// Stage all unstaged/untracked changes (equivalent to `git add -A`).
495pub fn stage_all_changes(repo_path: &Path) -> Result<(), String> {
496    let output = git_command()
497        .arg("add")
498        .arg("-A")
499        .current_dir(repo_path)
500        .output()
501        .map_err(|e| e.to_string())?;
502
503    if output.status.success() {
504        Ok(())
505    } else {
506        Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
507    }
508}
509
510/// Unstage all staged changes (equivalent to `git reset`).
511pub fn unstage_all_changes(repo_path: &Path) -> Result<(), String> {
512    let output =
513        git_command().arg("reset").current_dir(repo_path).output().map_err(|e| e.to_string())?;
514
515    if output.status.success() {
516        Ok(())
517    } else {
518        Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
519    }
520}
521
522/// Discard all staged, unstaged, and untracked changes in the repository.
523pub fn discard_all_changes(repo_path: &Path) -> Result<(), String> {
524    // 1. Unstage all first so everything is in the working tree
525    let _ = git_command().arg("reset").current_dir(repo_path).output();
526
527    // 2. Discard all tracked modifications
528    let checkout_out = git_command()
529        .arg("checkout")
530        .arg("--")
531        .arg(".")
532        .current_dir(repo_path)
533        .output()
534        .map_err(|e| e.to_string())?;
535
536    if !checkout_out.status.success() {
537        return Err(String::from_utf8_lossy(&checkout_out.stderr).trim().to_string());
538    }
539
540    // 3. Clean all untracked files/folders
541    let clean_out = git_command()
542        .arg("clean")
543        .arg("-fd")
544        .current_dir(repo_path)
545        .output()
546        .map_err(|e| e.to_string())?;
547
548    if !clean_out.status.success() {
549        return Err(String::from_utf8_lossy(&clean_out.stderr).trim().to_string());
550    }
551
552    Ok(())
553}
554
555/// Stage a single hunk of unstaged changes (equivalent to `git apply --cached -`).
556pub fn stage_hunk(repo_path: &Path, file_path: &str, hunk: &[DiffLine]) -> Result<(), String> {
557    apply_hunk_patch(repo_path, file_path, hunk, false, true)
558}
559
560/// Unstage a single hunk of staged changes (equivalent to `git apply --cached --reverse -`).
561pub fn unstage_hunk(repo_path: &Path, file_path: &str, hunk: &[DiffLine]) -> Result<(), String> {
562    apply_hunk_patch(repo_path, file_path, hunk, true, true)
563}
564
565/// Discard a single hunk of unstaged changes in the working tree (equivalent to `git apply --reverse -`).
566pub fn discard_hunk(repo_path: &Path, file_path: &str, hunk: &[DiffLine]) -> Result<(), String> {
567    apply_hunk_patch(repo_path, file_path, hunk, true, false)
568}
569
570/// Stage a single line from the Unstaged diff.
571pub fn stage_line(
572    repo_path: &Path,
573    file_path: &str,
574    hunk: &[DiffLine],
575    selected_line_idx: usize,
576) -> Result<(), String> {
577    apply_line_patch_inner(repo_path, file_path, hunk, selected_line_idx, false, false, true)
578}
579
580/// Unstage a single line from the Staged diff.
581pub fn unstage_line(
582    repo_path: &Path,
583    file_path: &str,
584    hunk: &[DiffLine],
585    selected_line_idx: usize,
586) -> Result<(), String> {
587    apply_line_patch_inner(repo_path, file_path, hunk, selected_line_idx, true, true, true)
588}
589
590/// Discard a single line from the Unstaged diff in the working tree.
591pub fn discard_line(
592    repo_path: &Path,
593    file_path: &str,
594    hunk: &[DiffLine],
595    selected_line_idx: usize,
596) -> Result<(), String> {
597    apply_line_patch_inner(repo_path, file_path, hunk, selected_line_idx, true, true, false)
598}
599
600fn parse_hunk_header(header: &str) -> Option<(usize, usize, usize, usize)> {
601    if !header.starts_with("@@") {
602        return None;
603    }
604    let parts: Vec<&str> = header.split("@@").collect();
605    if parts.len() < 3 {
606        return None;
607    }
608    let meta = parts[1].trim();
609    let subparts: Vec<&str> = meta.split_whitespace().collect();
610    if subparts.len() < 2 {
611        return None;
612    }
613
614    let parse_part = |p: &str| -> (usize, usize) {
615        let s = p.trim_start_matches(['-', '+']);
616        let comps: Vec<&str> = s.split(',').collect();
617        let start = comps[0].parse::<usize>().unwrap_or(0);
618        let count = if comps.len() > 1 { comps[1].parse::<usize>().unwrap_or(1) } else { 1 };
619        (start, count)
620    };
621
622    let (old_start, old_count) = parse_part(subparts[0]);
623    let (new_start, new_count) = parse_part(subparts[1]);
624    Some((old_start, old_count, new_start, new_count))
625}
626
627fn apply_line_patch_inner(
628    repo_path: &Path,
629    file_path: &str,
630    hunk: &[DiffLine],
631    selected_line_idx_in_hunk: usize,
632    revert: bool,
633    target_has_modification: bool,
634    cached: bool,
635) -> Result<(), String> {
636    use std::io::Write;
637    use std::process::{Command, Stdio};
638
639    let path_check = std::path::Path::new(file_path);
640    if path_check.is_absolute()
641        || path_check.components().any(|c| c == std::path::Component::ParentDir)
642    {
643        return Err("Path escapes repository root".to_string());
644    }
645
646    if hunk.is_empty() {
647        return Err("Empty hunk".to_string());
648    }
649
650    let selected_line = match hunk.get(selected_line_idx_in_hunk) {
651        Some(line) => line,
652        None => return Err("Invalid line index".to_string()),
653    };
654
655    if selected_line.kind != DiffLineKind::Added && selected_line.kind != DiffLineKind::Removed {
656        return Err("Selected line is not a modification (must be + or -)".to_string());
657    }
658
659    let header_line = &hunk[0];
660    let (old_start, _old_count, new_start, _new_count) =
661        match parse_hunk_header(&header_line.content) {
662            Some(coords) => coords,
663            None => return Err(format!("Invalid hunk header: {}", header_line.content)),
664        };
665
666    let mut patch_lines = Vec::new();
667    let mut new_old_count = 0;
668    let mut new_new_count = 0;
669
670    for (i, line) in hunk.iter().enumerate() {
671        if i == 0 {
672            continue;
673        }
674
675        if i == selected_line_idx_in_hunk {
676            if revert {
677                match line.kind {
678                    DiffLineKind::Added => {
679                        patch_lines.push(DiffLine {
680                            kind: DiffLineKind::Removed,
681                            content: line.content.clone(),
682                        });
683                        new_old_count += 1;
684                    }
685                    DiffLineKind::Removed => {
686                        patch_lines.push(DiffLine {
687                            kind: DiffLineKind::Added,
688                            content: line.content.clone(),
689                        });
690                        new_new_count += 1;
691                    }
692                    _ => {}
693                }
694            } else {
695                match line.kind {
696                    DiffLineKind::Added => {
697                        patch_lines.push(DiffLine {
698                            kind: DiffLineKind::Added,
699                            content: line.content.clone(),
700                        });
701                        new_new_count += 1;
702                    }
703                    DiffLineKind::Removed => {
704                        patch_lines.push(DiffLine {
705                            kind: DiffLineKind::Removed,
706                            content: line.content.clone(),
707                        });
708                        new_old_count += 1;
709                    }
710                    _ => {}
711                }
712            }
713        } else {
714            match line.kind {
715                DiffLineKind::Context => {
716                    patch_lines.push(line.clone());
717                    new_old_count += 1;
718                    new_new_count += 1;
719                }
720                DiffLineKind::Added => {
721                    if target_has_modification {
722                        patch_lines.push(DiffLine {
723                            kind: DiffLineKind::Context,
724                            content: line.content.clone(),
725                        });
726                        new_old_count += 1;
727                        new_new_count += 1;
728                    } else {
729                        // Omit
730                    }
731                }
732                DiffLineKind::Removed => {
733                    if target_has_modification {
734                        // Omit
735                    } else {
736                        patch_lines.push(DiffLine {
737                            kind: DiffLineKind::Context,
738                            content: line.content.clone(),
739                        });
740                        new_old_count += 1;
741                        new_new_count += 1;
742                    }
743                }
744                _ => {}
745            }
746        }
747    }
748
749    let mut patch = String::new();
750    patch.push_str(&format!("diff --git a/{} b/{}\n", file_path, file_path));
751    patch.push_str(&format!("--- a/{}\n", file_path));
752    patch.push_str(&format!("+++ b/{}\n", file_path));
753    patch.push_str(&format!(
754        "@@ -{},{} +{},{} @@\n",
755        old_start, new_old_count, new_start, new_new_count
756    ));
757
758    for line in patch_lines {
759        let prefix = match line.kind {
760            DiffLineKind::Added => "+",
761            DiffLineKind::Removed => "-",
762            DiffLineKind::Context => " ",
763            DiffLineKind::Header => "",
764            _ => "",
765        };
766        patch.push_str(prefix);
767        patch.push_str(&line.content);
768        patch.push('\n');
769    }
770
771    let mut args = vec!["apply"];
772    if cached {
773        args.push("--cached");
774    }
775    args.push("-");
776
777    let mut cmd = Command::new("git");
778    let mut child = cmd
779        .env("GIT_TERMINAL_PROMPT", "0")
780        .env("GIT_SSH_COMMAND", ssh_command_val())
781        .args(&args)
782        .current_dir(repo_path)
783        .stdin(Stdio::piped())
784        .stdout(Stdio::piped())
785        .stderr(Stdio::piped())
786        .spawn()
787        .map_err(|e| format!("Failed to spawn git apply: {}", e))?;
788
789    if let Some(mut stdin) = child.stdin.take() {
790        stdin
791            .write_all(patch.as_bytes())
792            .map_err(|e| format!("Failed to write patch to stdin: {}", e))?;
793    }
794
795    let output =
796        child.wait_with_output().map_err(|e| format!("Failed to wait for git apply: {}", e))?;
797
798    if !output.status.success() {
799        let err_msg = String::from_utf8_lossy(&output.stderr).to_string();
800        return Err(format!("git apply failed: {}", err_msg.trim()));
801    }
802
803    Ok(())
804}
805
806fn apply_hunk_patch(
807    repo_path: &Path,
808    file_path: &str,
809    hunk: &[DiffLine],
810    reverse: bool,
811    cached: bool,
812) -> Result<(), String> {
813    use std::io::Write;
814    use std::process::{Command, Stdio};
815
816    let path_check = std::path::Path::new(file_path);
817    if path_check.is_absolute()
818        || path_check.components().any(|c| c == std::path::Component::ParentDir)
819    {
820        return Err("Path escapes repository root".to_string());
821    }
822
823    let mut patch = String::new();
824    patch.push_str(&format!("diff --git a/{} b/{}\n", file_path, file_path));
825    patch.push_str(&format!("--- a/{}\n", file_path));
826    patch.push_str(&format!("+++ b/{}\n", file_path));
827    for line in hunk {
828        let prefix = match line.kind {
829            DiffLineKind::Added => "+",
830            DiffLineKind::Removed => "-",
831            DiffLineKind::Context => " ",
832            DiffLineKind::Header => "",
833            _ => "",
834        };
835        patch.push_str(prefix);
836        patch.push_str(&line.content);
837        patch.push('\n');
838    }
839
840    let mut args = vec!["apply"];
841    if cached {
842        args.push("--cached");
843    }
844    if reverse {
845        args.push("--reverse");
846    }
847    args.push("-");
848
849    let mut cmd = Command::new("git");
850    let mut child = cmd
851        .env("GIT_TERMINAL_PROMPT", "0")
852        .env("GIT_SSH_COMMAND", ssh_command_val())
853        .args(&args)
854        .current_dir(repo_path)
855        .stdin(Stdio::piped())
856        .stdout(Stdio::piped())
857        .stderr(Stdio::piped())
858        .spawn()
859        .map_err(|e| format!("Failed to spawn git apply: {}", e))?;
860
861    if let Some(mut stdin) = child.stdin.take() {
862        stdin
863            .write_all(patch.as_bytes())
864            .map_err(|e| format!("Failed to write patch to stdin: {}", e))?;
865    }
866
867    let output =
868        child.wait_with_output().map_err(|e| format!("Failed to wait for git apply: {}", e))?;
869
870    if !output.status.success() {
871        let err_msg = String::from_utf8_lossy(&output.stderr).to_string();
872        return Err(format!("git apply failed: {}", err_msg.trim()));
873    }
874
875    Ok(())
876}
877
878/// Discards uncommitted changes in `file_path`.
879/// - If the file is untracked, it is deleted from the filesystem.
880/// - If the file is tracked and modified/deleted, it is restored from the index.
881/// - If the file is staged, it is first unstaged (reset to HEAD) and then restored from index.
882pub fn discard_file_changes(repo_path: &Path, file_path: &str, staged: bool) -> Result<(), String> {
883    let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
884
885    if staged {
886        // First unstage it (reset to HEAD)
887        unstage_file(repo_path, file_path)?;
888    }
889
890    // Now check if the file is untracked
891    let is_untracked = if let Ok(status) = repo.status_file(Path::new(file_path)) {
892        status.contains(git2::Status::WT_NEW)
893    } else {
894        false
895    };
896
897    if is_untracked {
898        let full_path = repo_path.join(file_path);
899        if full_path.exists() {
900            if full_path.is_file() {
901                std::fs::remove_file(&full_path).map_err(|e| e.to_string())?;
902            } else if full_path.is_dir() {
903                std::fs::remove_dir_all(&full_path).map_err(|e| e.to_string())?;
904            }
905        }
906    } else {
907        // Tracked file: checkout from index to working tree
908        let mut checkout_opts = git2::build::CheckoutBuilder::new();
909        checkout_opts.path(Path::new(file_path));
910        checkout_opts.force();
911        repo.checkout_index(None, Some(&mut checkout_opts)).map_err(|e| e.to_string())?;
912    }
913
914    Ok(())
915}
916
917/// Create a commit in the repository with the given message.
918/// Returns a human-readable error string on failure.
919pub fn commit_changes(repo_path: &Path, message: &str) -> Result<(), String> {
920    let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
921    let mut index = repo.index().map_err(|e| e.to_string())?;
922    let tree_id = index.write_tree().map_err(|e| e.to_string())?;
923    let tree = repo.find_tree(tree_id).map_err(|e| e.to_string())?;
924
925    let signature = repo
926        .signature()
927        .map_err(|e| format!("Failed to get signature. Check user.name/email config: {}", e))?;
928
929    // Find parent commits
930    let mut parents = Vec::new();
931    let mut has_head = false;
932    if let Ok(head) = repo.head() {
933        if let Ok(parent_commit) = head.peel_to_commit() {
934            has_head = true;
935            // Check if there are changes staged compared to HEAD
936            let parent_tree = parent_commit.tree().map_err(|e| e.to_string())?;
937            if parent_tree.id() == tree_id {
938                return Err("No staged changes to commit".to_string());
939            }
940            parents.push(parent_commit);
941        }
942    }
943
944    if !has_head && index.is_empty() {
945        return Err("No staged changes to commit (index is empty)".to_string());
946    }
947
948    let parent_refs: Vec<&git2::Commit> = parents.iter().collect();
949
950    repo.commit(Some("HEAD"), &signature, &signature, message, &tree, &parent_refs)
951        .map_err(|e| e.to_string())?;
952
953    Ok(())
954}
955
956// ── Public entry points ────────────────────────────────────────────────────
957
958/// Expand a leading `~` or `~/` in a user-supplied path to the user's home
959/// directory. Returns the input unchanged if there is no home dir or no
960/// tilde to expand.
961pub fn expand_tilde(s: &str) -> PathBuf {
962    if s == "~" {
963        return dirs::home_dir().unwrap_or_else(|| PathBuf::from(s));
964    }
965    if let Some(stripped) = s.strip_prefix("~/")
966        && let Some(home) = dirs::home_dir()
967    {
968        return home.join(stripped);
969    }
970    PathBuf::from(s)
971}
972
973/// Add a new git remote.
974pub fn remote_add(repo_path: &std::path::Path, name: &str, url: &str) -> Result<(), git2::Error> {
975    let repo = Repository::open(repo_path)?;
976    repo.remote(name, url)?;
977    Ok(())
978}
979
980/// Delete an existing git remote.
981pub fn remote_delete(repo_path: &std::path::Path, name: &str) -> Result<(), git2::Error> {
982    let repo = Repository::open(repo_path)?;
983    repo.remote_delete(name)?;
984    Ok(())
985}
986
987/// Classify `item` and produce a card-level summary. Used by the list view.
988pub fn inspect_summary(item: &str) -> ItemStatus {
989    let path = expand_tilde(item);
990    if !path.is_dir() {
991        return ItemStatus::Missing;
992    }
993    if !path.join(".git").exists() {
994        return ItemStatus::Directory;
995    }
996    match Repository::open(&path) {
997        Ok(repo) => ItemStatus::GitRepo(Some(collect_summary(&repo))),
998        Err(_) => ItemStatus::GitRepo(None),
999    }
1000}
1001
1002/// Inspect `item` and produce the rich detail report shown on Enter.
1003pub fn inspect_detail(
1004    item: &str,
1005    commit_limit: usize,
1006    graph_max_commits: usize,
1007    enable_commit_signatures: bool,
1008) -> ItemDetail {
1009    let resolved = expand_tilde(item);
1010    if !resolved.is_dir() {
1011        return ItemDetail::Missing { resolved };
1012    }
1013    if !resolved.join(".git").exists() {
1014        return ItemDetail::Directory { resolved };
1015    }
1016    match collect_info(&resolved, commit_limit, graph_max_commits, enable_commit_signatures) {
1017        Ok(info) => ItemDetail::Repo { resolved, info: Box::new(info) },
1018        Err(e) => ItemDetail::Error { resolved, message: e.to_string() },
1019    }
1020}
1021
1022fn collect_signatures(repo_path: &Path, limit: usize) -> std::collections::HashMap<String, String> {
1023    let mut sigs = std::collections::HashMap::new();
1024    let mut cmd = std::process::Command::new("git");
1025    cmd.env("GIT_TERMINAL_PROMPT", "0")
1026        .env("GIT_SSH_COMMAND", ssh_command_val())
1027        .arg("log")
1028        .arg("--all");
1029
1030    if limit > 0 {
1031        cmd.arg(format!("-n{}", limit));
1032    }
1033
1034    cmd.arg("--pretty=format:%H %G?").current_dir(repo_path);
1035
1036    if let Ok(out) = cmd.output() {
1037        if out.status.success() {
1038            let stdout_str = String::from_utf8_lossy(&out.stdout);
1039            for line in stdout_str.lines() {
1040                let parts: Vec<&str> = line.split_whitespace().collect();
1041                if parts.len() == 2 {
1042                    sigs.insert(parts[0].to_string(), parts[1].to_string());
1043                } else if parts.len() == 1 {
1044                    sigs.insert(parts[0].to_string(), "N".to_string());
1045                }
1046            }
1047        }
1048    }
1049    sigs
1050}
1051
1052#[derive(serde::Serialize, serde::Deserialize, Clone)]
1053struct CachedCommit {
1054    id: String,
1055    author: String,
1056    date: String,
1057    summary: String,
1058    message: String,
1059    time: i64,
1060}
1061
1062fn hash_path(path: &Path) -> String {
1063    use std::collections::hash_map::DefaultHasher;
1064    use std::hash::{Hash, Hasher};
1065    let mut hasher = DefaultHasher::new();
1066    path.hash(&mut hasher);
1067    format!("{:x}", hasher.finish())
1068}
1069
1070fn collect_commits(
1071    repo: &Repository,
1072    limit: usize,
1073    repo_path: &Path,
1074    enable_commit_signatures: bool,
1075) -> Result<Vec<CommitEntry>, git2::Error> {
1076    let mut walk = repo.revwalk()?;
1077    if walk.push_head().is_err() {
1078        return Ok(Vec::new());
1079    }
1080    walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)?;
1081
1082    let mut commits = Vec::new();
1083    let oids: Vec<Result<git2::Oid, git2::Error>> =
1084        if limit > 0 { walk.take(limit).collect() } else { walk.collect() };
1085
1086    let sig_map = if enable_commit_signatures {
1087        collect_signatures(repo_path, limit)
1088    } else {
1089        std::collections::HashMap::new()
1090    };
1091    let ref_map = get_cached_ref_map(repo, repo_path);
1092
1093    // Load commit cache
1094    let cache_dir = dirs::home_dir().map(|h| h.join(".gitwig/commit_cache"));
1095    if let Some(ref dir) = cache_dir {
1096        let _ = std::fs::create_dir_all(dir);
1097    }
1098    let hash = hash_path(repo_path);
1099    let cache_file = cache_dir.as_ref().map(|d| d.join(format!("{}.json", hash)));
1100    let mut cache: std::collections::HashMap<String, CachedCommit> = cache_file
1101        .as_ref()
1102        .and_then(|f| std::fs::read_to_string(f).ok())
1103        .and_then(|s| serde_json::from_str(&s).ok())
1104        .unwrap_or_default();
1105
1106    let mut cache_updated = false;
1107
1108    for id in oids {
1109        let oid = id?;
1110        let oid_str = oid.to_string();
1111        let sig_status = sig_map.get(&oid_str).cloned().unwrap_or_else(|| "N".to_string());
1112        let refs = ref_map.get(&oid).cloned().unwrap_or_default();
1113        let files = Vec::new();
1114
1115        if let Some(cached) = cache.get(&oid_str) {
1116            let when = format_relative_time(cached.time);
1117            commits.push(CommitEntry {
1118                id: cached.id.clone(),
1119                oid: oid_str,
1120                author: sanitize_text(&cached.author),
1121                when,
1122                date: cached.date.clone(),
1123                summary: sanitize_text(&cached.summary),
1124                message: sanitize_text(&cached.message),
1125                refs,
1126                files,
1127                signature_status: sig_status,
1128            });
1129        } else if let Ok(commit) = repo.find_commit(oid) {
1130            let short_id = format!("{:.7}", commit.id());
1131            let summary =
1132                sanitize_text(commit.summary().ok().flatten().unwrap_or("(no commit message)"));
1133            let author = commit.author();
1134            let author_name = author.name().unwrap_or("?");
1135            let author_email = author.email().unwrap_or("?");
1136            let author_str = sanitize_text(&format!("{} <{}>", author_name, author_email));
1137            let time_secs = commit.time().seconds();
1138            let when = format_relative_time(time_secs);
1139            let date = format_utc_date(time_secs);
1140            let message = sanitize_text(commit.message().unwrap_or("(no commit message)"));
1141
1142            let cached = CachedCommit {
1143                id: short_id.clone(),
1144                author: author_str.clone(),
1145                date: date.clone(),
1146                summary: summary.clone(),
1147                message: message.clone(),
1148                time: time_secs,
1149            };
1150            cache.insert(oid_str.clone(), cached);
1151            cache_updated = true;
1152
1153            commits.push(CommitEntry {
1154                id: short_id,
1155                oid: oid_str,
1156                author: author_str,
1157                when,
1158                date,
1159                summary,
1160                message,
1161                refs,
1162                files,
1163                signature_status: sig_status,
1164            });
1165        }
1166    }
1167
1168    if cache_updated {
1169        if let Some(ref f) = cache_file {
1170            if let Ok(json) = serde_json::to_string(&cache) {
1171                let _ = std::fs::write(f, json);
1172            }
1173        }
1174    }
1175
1176    Ok(commits)
1177}
1178
1179pub fn get_file_history(repo_path: &Path, file_path: &str) -> Result<Vec<FileRevision>, String> {
1180    let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1181    let mut walk = repo.revwalk().map_err(|e| e.to_string())?;
1182    if walk.push_head().is_err() {
1183        return Ok(Vec::new());
1184    }
1185    walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME).map_err(|e| e.to_string())?;
1186
1187    let mut revisions = Vec::new();
1188
1189    for oid_res in walk {
1190        let oid = oid_res.map_err(|e| e.to_string())?;
1191        let commit = repo.find_commit(oid).map_err(|e| e.to_string())?;
1192
1193        let commit_tree = commit.tree().map_err(|e| e.to_string())?;
1194        let mut modified = false;
1195
1196        if commit.parent_count() > 0 {
1197            for i in 0..commit.parent_count() {
1198                if let Ok(parent) = commit.parent(i) {
1199                    if let Ok(parent_tree) = parent.tree() {
1200                        let mut diff_opts = git2::DiffOptions::new();
1201                        diff_opts.pathspec(file_path);
1202                        if let Ok(diff) = repo.diff_tree_to_tree(
1203                            Some(&parent_tree),
1204                            Some(&commit_tree),
1205                            Some(&mut diff_opts),
1206                        ) {
1207                            if diff.deltas().len() > 0 {
1208                                modified = true;
1209                                break;
1210                            }
1211                        }
1212                    }
1213                }
1214            }
1215        } else {
1216            let mut diff_opts = git2::DiffOptions::new();
1217            diff_opts.pathspec(file_path);
1218            if let Ok(diff) = repo.diff_tree_to_tree(None, Some(&commit_tree), Some(&mut diff_opts))
1219            {
1220                if diff.deltas().len() > 0 {
1221                    modified = true;
1222                }
1223            }
1224        }
1225
1226        if modified {
1227            let author = commit.author();
1228            let author_name = author.name().unwrap_or("?");
1229            let author_email = author.email().unwrap_or("?");
1230            let author_str = sanitize_text(&format!("{} <{}>", author_name, author_email));
1231            let time_secs = commit.time().seconds();
1232            let when = format_relative_time(time_secs);
1233            let date = format_utc_date(time_secs);
1234            let summary =
1235                sanitize_text(commit.summary().ok().flatten().unwrap_or("(no commit message)"));
1236
1237            revisions.push(FileRevision {
1238                commit_oid: oid.to_string(),
1239                author: author_str,
1240                date,
1241                when,
1242                summary,
1243            });
1244        }
1245    }
1246
1247    Ok(revisions)
1248}
1249
1250fn collect_committer_stats(
1251    repo: &Repository,
1252    limit: usize,
1253) -> Result<(Vec<CommitterStat>, bool), git2::Error> {
1254    let mut walk = repo.revwalk()?;
1255    if walk.push_head().is_err() {
1256        return Ok((Vec::new(), false));
1257    }
1258    let mut counts = std::collections::HashMap::new();
1259    let mut count = 0;
1260    let mut limit_reached = false;
1261    for id in walk {
1262        let oid = id?;
1263        if let Ok(commit) = repo.find_commit(oid) {
1264            let author = commit.author();
1265            let name = author.name().unwrap_or("?").to_string();
1266            let email = author.email().unwrap_or("?").to_string();
1267            let key = (name, email);
1268            *counts.entry(key).or_insert(0) += 1;
1269            count += 1;
1270            if count >= limit {
1271                limit_reached = true;
1272                break;
1273            }
1274        }
1275    }
1276
1277    let mut stats: Vec<CommitterStat> = counts
1278        .into_iter()
1279        .map(|((name, email), count)| CommitterStat { name, email, count })
1280        .collect();
1281
1282    stats.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.name.cmp(&b.name)));
1283
1284    Ok((stats, limit_reached))
1285}
1286
1287/// Diff `commit` against its first parent (or against an empty tree for the
1288/// initial commit) and return the list of changed files. Capped at
1289/// `MAX_FILES_PER_SECTION` entries.
1290fn commit_changed_files(repo: &Repository, commit: &git2::Commit) -> Vec<FileEntry> {
1291    let commit_tree = match commit.tree() {
1292        Ok(t) => t,
1293        Err(_) => return Vec::new(),
1294    };
1295    // For the initial commit parent_tree is None — libgit2 treats that as an
1296    // empty tree, so all files appear as "added".
1297    let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok());
1298
1299    let diff = match repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), None) {
1300        Ok(d) => d,
1301        Err(_) => return Vec::new(),
1302    };
1303
1304    let mut files = Vec::new();
1305    for delta in diff.deltas() {
1306        if files.len() >= MAX_FILES_PER_SECTION {
1307            break;
1308        }
1309        let path = delta
1310            .new_file()
1311            .path()
1312            .or_else(|| delta.old_file().path())
1313            .map(|p| p.to_string_lossy().into_owned())
1314            .unwrap_or_else(|| "(unknown)".to_string());
1315
1316        let label: &'static str = match delta.status() {
1317            git2::Delta::Added => "N",
1318            git2::Delta::Deleted => "D",
1319            git2::Delta::Modified => "M",
1320            git2::Delta::Renamed => "R",
1321            git2::Delta::Typechange => "T",
1322            _ => "M",
1323        };
1324        files.push(FileEntry { path, label });
1325    }
1326    files
1327}
1328
1329pub fn get_commit_files(repo_path: &Path, oid: &str) -> Result<Vec<FileEntry>, String> {
1330    let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1331    let oid = git2::Oid::from_str(oid).map_err(|e| e.to_string())?;
1332    let commit = repo.find_commit(oid).map_err(|e| e.to_string())?;
1333    Ok(commit_changed_files(&repo, &commit))
1334}
1335
1336// ── Internal collection ────────────────────────────────────────────────────
1337
1338fn collect_info(
1339    path: &Path,
1340    commit_limit: usize,
1341    _graph_max_commits: usize,
1342    enable_commit_signatures: bool,
1343) -> Result<RepoInfo, git2::Error> {
1344    let repo = Repository::open(path)?;
1345    let mut summary = RepoSummary::default();
1346    if let Ok(head) = repo.head() {
1347        summary.branch = head.shorthand().ok().map(String::from);
1348    }
1349    populate_ahead_behind(&repo, &mut summary);
1350
1351    let mut info = RepoInfo { summary, ..RepoInfo::default() };
1352
1353    if let Ok(head) = repo.head() {
1354        info.branch = head.shorthand().ok().map(String::from);
1355
1356        if let Ok(commit) = head.peel_to_commit() {
1357            let short_id = format!("{:.7}", commit.id());
1358            let summary_text =
1359                sanitize_text(commit.summary().ok().flatten().unwrap_or("(no commit message)"));
1360            let author = commit.author();
1361            let author_str = sanitize_text(&format!(
1362                "{} <{}>",
1363                author.name().unwrap_or("?"),
1364                author.email().unwrap_or("?")
1365            ));
1366            let when = format_relative_time(commit.time().seconds());
1367            info.head =
1368                Some(HeadInfo { short_id, summary: summary_text, author: author_str, when });
1369        }
1370
1371        if let Ok(head_name) = head.name() {
1372            info.upstream = upstream_short_name(&repo, head_name);
1373        }
1374    }
1375
1376    if let Ok(commits) = collect_commits(&repo, commit_limit, path, enable_commit_signatures) {
1377        info.commits = commits;
1378    }
1379
1380    populate_summary_and_file_changes(&repo, &mut info);
1381
1382    if let Ok(remotes) = load_tab_remotes(path) {
1383        info.remotes = TabData::Loaded(remotes);
1384        info.tab_loaded_at[5] = Some(std::time::Instant::now());
1385    }
1386
1387    Ok(info)
1388}
1389
1390pub fn load_tab_reflog(repo_path: &Path) -> Result<Vec<ReflogEntry>, String> {
1391    let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1392    let reflog = repo.reflog("HEAD").map_err(|e| e.to_string())?;
1393    let mut entries = Vec::new();
1394    for (i, entry) in reflog.iter().enumerate() {
1395        let target_oid = format!("{}", entry.id_new());
1396        let selector = format!("HEAD@{{{}}}", i);
1397        let msg = entry.message().ok().flatten().unwrap_or("").to_string();
1398
1399        let (command, message) = if let Some(pos) = msg.find(':') {
1400            (msg[..pos].trim().to_string(), msg[pos + 1..].trim().to_string())
1401        } else {
1402            (String::new(), msg)
1403        };
1404
1405        let sig = entry.committer();
1406        let when_secs = sig.when().seconds();
1407        let when = format_relative_time(when_secs);
1408        let date = format_utc_date(when_secs);
1409
1410        entries.push(ReflogEntry { index: i, target_oid, selector, command, message, when, date });
1411    }
1412    Ok(entries)
1413}
1414
1415pub fn checkout_commit(repo_path: &Path, commit_oid: &str) -> Result<(), git2::Error> {
1416    let output = std::process::Command::new("git")
1417        .env("GIT_TERMINAL_PROMPT", "0")
1418        .env("GIT_SSH_COMMAND", ssh_command_val())
1419        .arg("checkout")
1420        .arg(commit_oid)
1421        .current_dir(repo_path)
1422        .output()
1423        .map_err(|e| git2::Error::from_str(&e.to_string()))?;
1424
1425    if !output.status.success() {
1426        let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
1427        return Err(git2::Error::from_str(&err));
1428    }
1429    Ok(())
1430}
1431
1432pub fn load_tab_files(repo_path: &Path) -> Result<Vec<String>, String> {
1433    let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1434    let mut files = Vec::new();
1435    if let Ok(index) = repo.index() {
1436        for entry in index.iter() {
1437            if let Ok(path_str) = std::str::from_utf8(&entry.path) {
1438                files.push(path_str.to_string());
1439            }
1440        }
1441    }
1442    Ok(files)
1443}
1444
1445pub fn load_tab_graph_stream(
1446    repo_path: &Path,
1447    graph_max_commits: usize,
1448    repo_resolved_path: String,
1449    tab_idx: usize,
1450    tx: std::sync::mpsc::Sender<(String, usize, TabPayload)>,
1451) -> Result<Vec<GraphLine>, String> {
1452    let mut graph_lines = Vec::new();
1453    let format_str = "%H__TWIG_SEP__%d__TWIG_SEP__%s__TWIG_SEP__%an__TWIG_SEP__%ad__TWIG_SEP__%G?";
1454
1455    let mut args = vec![
1456        "log".to_string(),
1457        "--graph".to_string(),
1458        "--all".to_string(),
1459        "--date=relative".to_string(),
1460    ];
1461    if graph_max_commits > 0 {
1462        args.push(format!("--max-count={}", graph_max_commits));
1463    }
1464    args.push(format!("--pretty=format:{}", format_str));
1465    args.push("--color=never".to_string());
1466
1467    let mut child = std::process::Command::new("git")
1468        .env("GIT_TERMINAL_PROMPT", "0")
1469        .env("GIT_SSH_COMMAND", ssh_command_val())
1470        .args(&args)
1471        .current_dir(repo_path)
1472        .stdout(std::process::Stdio::piped())
1473        .spawn()
1474        .map_err(|e| e.to_string())?;
1475
1476    let stdout = child.stdout.take().ok_or_else(|| "Failed to open stdout".to_string())?;
1477    let reader = std::io::BufReader::new(stdout);
1478    use std::io::BufRead;
1479
1480    for (idx, line_res) in reader.lines().enumerate() {
1481        let line = line_res.map_err(|e| e.to_string())?;
1482        let parsed = parse_graph_line(&line);
1483        graph_lines.push(parsed);
1484
1485        // Every 200 lines, send a cloned batch to UI
1486        if (idx + 1) % 200 == 0 {
1487            let _ = tx.send((
1488                repo_resolved_path.clone(),
1489                tab_idx,
1490                TabPayload::Graph(Ok(graph_lines.clone())),
1491            ));
1492        }
1493    }
1494
1495    // Wait for the child process to exit
1496    let status = child.wait().map_err(|e| e.to_string())?;
1497    if !status.success() && graph_lines.is_empty() {
1498        return Err("git log failed".to_string());
1499    }
1500
1501    Ok(graph_lines)
1502}
1503
1504pub fn load_tab_branches(
1505    repo_path: &Path,
1506) -> (Result<Vec<BranchInfo>, String>, Result<Vec<BranchInfo>, String>) {
1507    let repo = match Repository::open(repo_path) {
1508        Ok(r) => r,
1509        Err(e) => return (Err(e.to_string()), Err(e.to_string())),
1510    };
1511
1512    let mut local_branches = Vec::new();
1513    if let Ok(branches) = repo.branches(Some(git2::BranchType::Local)) {
1514        for (branch, _) in branches.flatten() {
1515            if let Ok(Some(name)) = branch.name() {
1516                let is_head = branch.is_head();
1517                let mut short_sha = String::new();
1518                let mut short_message = String::new();
1519                if let Ok(target) = branch.get().peel_to_commit() {
1520                    let id = target.id();
1521                    let id_str = id.to_string();
1522                    short_sha = safe_sha_slice(&id_str, 7).to_string();
1523                    if let Ok(Some(summary)) = target.summary() {
1524                        short_message = summary.to_string();
1525                    }
1526                }
1527                local_branches.push(BranchInfo {
1528                    name: sanitize_text(name),
1529                    is_head,
1530                    short_sha,
1531                    short_message: sanitize_text(&short_message),
1532                });
1533            }
1534        }
1535    }
1536    local_branches.sort_by(|a, b| b.is_head.cmp(&a.is_head).then_with(|| a.name.cmp(&b.name)));
1537
1538    let mut remote_branches = Vec::new();
1539    if let Ok(branches) = repo.branches(Some(git2::BranchType::Remote)) {
1540        for (branch, _) in branches.flatten() {
1541            if let Ok(Some(name)) = branch.name() {
1542                if !name.ends_with("/HEAD") {
1543                    let is_head = branch.is_head();
1544                    let mut short_sha = String::new();
1545                    let mut short_message = String::new();
1546                    if let Ok(target) = branch.get().peel_to_commit() {
1547                        let id = target.id();
1548                        let id_str = id.to_string();
1549                        short_sha = safe_sha_slice(&id_str, 7).to_string();
1550                        if let Ok(Some(summary)) = target.summary() {
1551                            short_message = summary.to_string();
1552                        }
1553                    }
1554                    remote_branches.push(BranchInfo {
1555                        name: sanitize_text(name),
1556                        is_head,
1557                        short_sha,
1558                        short_message: sanitize_text(&short_message),
1559                    });
1560                }
1561            }
1562        }
1563    }
1564    remote_branches.sort_by(|a, b| a.name.cmp(&b.name));
1565
1566    (Ok(local_branches), Ok(remote_branches))
1567}
1568
1569pub fn load_tab_tags(
1570    repo_path: &Path,
1571) -> (Result<Vec<BranchInfo>, String>, Result<Vec<BranchInfo>, String>) {
1572    let repo = match Repository::open(repo_path) {
1573        Ok(r) => r,
1574        Err(e) => return (Err(e.to_string()), Err(e.to_string())),
1575    };
1576
1577    let mut local_tags = Vec::new();
1578    if let Ok(tags) = repo.tag_names(None) {
1579        for tag_opt in tags.iter() {
1580            if let Ok(Some(tag)) = tag_opt {
1581                let mut short_sha = String::new();
1582                let mut short_message = String::new();
1583                if let Ok(reference) = repo.find_reference(&format!("refs/tags/{}", tag)) {
1584                    if let Ok(target) = reference.peel_to_commit() {
1585                        let id = target.id();
1586                        let id_str = id.to_string();
1587                        short_sha = safe_sha_slice(&id_str, 7).to_string();
1588                        if let Ok(Some(summary)) = target.summary() {
1589                            short_message = summary.to_string();
1590                        }
1591                    }
1592                }
1593                local_tags.push(BranchInfo {
1594                    name: sanitize_text(tag),
1595                    is_head: false,
1596                    short_sha,
1597                    short_message: sanitize_text(&short_message),
1598                });
1599            }
1600        }
1601    }
1602    local_tags.sort_by(|a, b| b.name.cmp(&a.name));
1603
1604    (Ok(local_tags), Ok(Vec::new()))
1605}
1606
1607pub fn load_tab_remotes(repo_path: &Path) -> Result<Vec<RemoteInfo>, String> {
1608    let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1609    let mut remotes_list = Vec::new();
1610    if let Ok(remotes) = repo.remotes() {
1611        for name in remotes.iter() {
1612            let Ok(Some(name)) = name else { continue };
1613            if let Ok(remote) = repo.find_remote(name) {
1614                let push_url = remote.pushurl().ok().flatten().map(String::from);
1615                let mut refspecs = Vec::new();
1616                for r in remote.refspecs() {
1617                    if let Ok(s) = r.str() {
1618                        refspecs.push(s.to_string());
1619                    }
1620                }
1621                remotes_list.push(RemoteInfo {
1622                    name: name.to_string(),
1623                    url: remote.url().unwrap_or("(no url)").to_string(),
1624                    push_url,
1625                    refspecs,
1626                });
1627            }
1628        }
1629    }
1630    Ok(remotes_list)
1631}
1632
1633pub fn load_tab_stashes(repo_path: &Path) -> Result<Vec<StashInfo>, String> {
1634    let mut repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1635    let mut temp_stashes = Vec::new();
1636    let _ = repo.stash_foreach(|index, message, oid| {
1637        temp_stashes.push((index, message.to_string(), *oid));
1638        true
1639    });
1640
1641    let mut stashes = Vec::new();
1642    for (index, message, oid) in temp_stashes {
1643        let mut files = Vec::new();
1644        if let Ok(commit) = repo.find_commit(oid) {
1645            files = commit_changed_files(&repo, &commit);
1646        }
1647        stashes.push(StashInfo {
1648            index,
1649            message: sanitize_text(&message),
1650            commit_id: oid.to_string(),
1651            files,
1652        });
1653    }
1654    Ok(stashes)
1655}
1656
1657pub fn load_tab_overview(
1658    repo_path: &Path,
1659    commit_limit: usize,
1660) -> Result<(Vec<CommitterStat>, bool), String> {
1661    let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1662    let stats_limit = if commit_limit > 0 { commit_limit.min(10000) } else { 10000 };
1663    let (stats, limit_reached) =
1664        collect_committer_stats(&repo, stats_limit).map_err(|e| e.to_string())?;
1665    Ok((stats, limit_reached))
1666}
1667
1668pub fn load_tab_submodules(repo_path: &Path) -> Result<Vec<SubmoduleInfo>, String> {
1669    let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
1670    let submodules = repo.submodules().map_err(|e| e.to_string())?;
1671    let mut list = Vec::new();
1672    for sub in submodules {
1673        let name = sub.name().unwrap_or("").to_string();
1674        let path = sub.path().to_path_buf();
1675        let url = sub.url().unwrap_or(None).unwrap_or("").to_string();
1676        let commit_id = sub.index_id().map(|id| id.to_string());
1677        let head_id = sub.head_id().map(|id| id.to_string());
1678
1679        let mut is_initialized = true;
1680        let mut is_dirty = false;
1681
1682        if let Ok(status) = repo.submodule_status(&name, git2::SubmoduleIgnore::None) {
1683            is_initialized = !status.contains(git2::SubmoduleStatus::WD_UNINITIALIZED);
1684            is_dirty = status.contains(git2::SubmoduleStatus::WD_MODIFIED)
1685                || status.contains(git2::SubmoduleStatus::WD_WD_MODIFIED)
1686                || status.contains(git2::SubmoduleStatus::WD_UNTRACKED)
1687                || status.contains(git2::SubmoduleStatus::WD_INDEX_MODIFIED)
1688                || status.contains(git2::SubmoduleStatus::INDEX_MODIFIED);
1689        }
1690
1691        list.push(SubmoduleInfo { name, path, url, commit_id, head_id, is_initialized, is_dirty });
1692    }
1693    Ok(list)
1694}
1695
1696/// Build a map from commit `Oid` → list of ref names that point to it.
1697/// Local branches are stored as plain names (e.g. `"main"`).
1698/// Lightweight and annotated tags are stored with a `"tag:"` prefix
1699/// (e.g. `"tag:v1.0"`) so the UI can colour them differently.
1700fn build_ref_map(repo: &Repository) -> std::collections::HashMap<git2::Oid, Vec<String>> {
1701    let mut map: std::collections::HashMap<git2::Oid, Vec<String>> =
1702        std::collections::HashMap::new();
1703
1704    if let Ok(refs) = repo.references() {
1705        for reference in refs.flatten() {
1706            // Resolve to the underlying commit Oid (peeling through tags).
1707            let Ok(target) = reference.peel_to_commit() else {
1708                continue;
1709            };
1710            let oid = target.id();
1711
1712            let Ok(full_name) = reference.name() else {
1713                continue;
1714            };
1715
1716            let label = if let Some(branch) = full_name.strip_prefix("refs/heads/") {
1717                branch.to_string()
1718            } else if let Some(tag) = full_name.strip_prefix("refs/tags/") {
1719                format!("tag:{}", tag)
1720            } else if let Some(remote) = full_name.strip_prefix("refs/remotes/") {
1721                // Skip the symbolic HEAD pointer each remote keeps (e.g. origin/HEAD).
1722                if remote.ends_with("/HEAD") {
1723                    continue;
1724                }
1725                format!("remote:{}", remote)
1726            } else {
1727                continue;
1728            };
1729
1730            map.entry(oid).or_default().push(label);
1731        }
1732    }
1733    map
1734}
1735
1736#[allow(clippy::type_complexity)]
1737static REF_MAP_CACHE: std::sync::OnceLock<
1738    std::sync::Mutex<
1739        std::collections::HashMap<
1740            String,
1741            (std::collections::HashMap<git2::Oid, Vec<String>>, std::time::Instant),
1742        >,
1743    >,
1744> = std::sync::OnceLock::new();
1745
1746fn get_cached_ref_map(
1747    repo: &Repository,
1748    repo_path: &Path,
1749) -> std::collections::HashMap<git2::Oid, Vec<String>> {
1750    let cache_lock =
1751        REF_MAP_CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()));
1752    let mut cache = cache_lock.lock().unwrap_or_else(|e| e.into_inner());
1753    let path_key = repo_path.to_string_lossy().to_string();
1754
1755    if let Some((map, loaded_at)) = cache.get(&path_key) {
1756        if loaded_at.elapsed() < std::time::Duration::from_secs(10) {
1757            return map.clone();
1758        }
1759    }
1760
1761    let map = build_ref_map(repo);
1762    cache.insert(path_key, (map.clone(), std::time::Instant::now()));
1763    map
1764}
1765
1766pub fn invalidate_ref_map_cache(repo_path: &Path) {
1767    if let Some(cache_lock) = REF_MAP_CACHE.get() {
1768        if let Ok(mut cache) = cache_lock.lock() {
1769            cache.remove(&repo_path.to_string_lossy().to_string());
1770        }
1771    }
1772}
1773
1774/// Maximum file entries collected per bucket. Prevents pathologically large
1775/// working trees from overwhelming the detail view.
1776const MAX_FILES_PER_SECTION: usize = 100;
1777
1778/// Walk the working-tree status once and collect both summary counts and per-file info.
1779fn populate_summary_and_file_changes(repo: &Repository, info: &mut RepoInfo) {
1780    let mut opts = StatusOptions::new();
1781    opts.include_untracked(true)
1782        .renames_head_to_index(true)
1783        .recurse_untracked_dirs(true)
1784        .show(StatusShow::IndexAndWorkdir);
1785    let Ok(statuses) = repo.statuses(Some(&mut opts)) else {
1786        return;
1787    };
1788    for entry in statuses.iter() {
1789        let path = entry.path().unwrap_or("(unknown)").to_string();
1790        let flags = entry.status();
1791
1792        // 1. Populate summary counters
1793        if flags.is_conflicted() {
1794            info.summary.conflicted += 1;
1795        } else {
1796            if flags.is_wt_new() {
1797                info.summary.untracked += 1;
1798            }
1799            if flags.is_wt_modified()
1800                || flags.is_wt_deleted()
1801                || flags.is_wt_renamed()
1802                || flags.is_wt_typechange()
1803            {
1804                info.summary.modified += 1;
1805            }
1806            if flags.is_index_new()
1807                || flags.is_index_modified()
1808                || flags.is_index_deleted()
1809                || flags.is_index_renamed()
1810                || flags.is_index_typechange()
1811            {
1812                info.summary.staged += 1;
1813            }
1814        }
1815
1816        // 2. Populate file entries
1817        // Skip directories to avoid showing folders in staging panels
1818        let path_buf = repo.workdir().unwrap_or(Path::new("")).join(&path);
1819        if path_buf.is_dir() {
1820            continue;
1821        }
1822
1823        if flags.is_conflicted() {
1824            if info.changes.conflicted.len() < MAX_FILES_PER_SECTION {
1825                info.changes.conflicted.push(FileEntry { path: path.clone(), label: "C" });
1826            }
1827            continue;
1828        }
1829
1830        // Index (staged) changes
1831        if (flags.is_index_new()
1832            || flags.is_index_modified()
1833            || flags.is_index_deleted()
1834            || flags.is_index_renamed()
1835            || flags.is_index_typechange())
1836            && info.changes.staged.len() < MAX_FILES_PER_SECTION
1837        {
1838            let label = if flags.is_index_new() {
1839                "N"
1840            } else if flags.is_index_deleted() {
1841                "D"
1842            } else if flags.is_index_renamed() {
1843                "R"
1844            } else if flags.is_index_typechange() {
1845                "T"
1846            } else {
1847                "M"
1848            };
1849            info.changes.staged.push(FileEntry { path: path.clone(), label });
1850        }
1851
1852        // Working-tree changes
1853        if flags.is_wt_new() {
1854            if info.changes.untracked.len() < MAX_FILES_PER_SECTION {
1855                info.changes.untracked.push(FileEntry { path: path.clone(), label: "?" });
1856            }
1857            if info.changes.unstaged.len() < MAX_FILES_PER_SECTION {
1858                info.changes.unstaged.push(FileEntry { path: path.clone(), label: "N" });
1859            }
1860        } else if (flags.is_wt_modified()
1861            || flags.is_wt_deleted()
1862            || flags.is_wt_renamed()
1863            || flags.is_wt_typechange())
1864            && info.changes.unstaged.len() < MAX_FILES_PER_SECTION
1865        {
1866            let label = if flags.is_wt_deleted() {
1867                "D"
1868            } else if flags.is_wt_renamed() {
1869                "R"
1870            } else if flags.is_wt_typechange() {
1871                "T"
1872            } else {
1873                "M"
1874            };
1875            info.changes.unstaged.push(FileEntry { path: path.clone(), label });
1876        }
1877    }
1878}
1879
1880/// Collect the branch name, worktree counts, and ahead/behind for an opened
1881/// repo. Used by both `inspect_summary` (card) and `collect_info` (detail)
1882/// so the values shown in both places always agree.
1883fn collect_summary(repo: &Repository) -> RepoSummary {
1884    let mut s = RepoSummary::default();
1885    // git2 0.21: head() + shorthand() = Result<&str, Error>.
1886    if let Ok(head) = repo.head() {
1887        s.branch = head.shorthand().ok().map(String::from);
1888        if let Ok(commit) = head.peel_to_commit() {
1889            s.last_commit_time = Some(commit.time().seconds());
1890        }
1891    }
1892    populate_worktree(repo, &mut s);
1893    populate_ahead_behind(repo, &mut s);
1894    s.state = match repo.state() {
1895        git2::RepositoryState::Clean => RepoState::Clean,
1896        git2::RepositoryState::Merge => RepoState::Merge,
1897        git2::RepositoryState::Revert | git2::RepositoryState::RevertSequence => RepoState::Revert,
1898        git2::RepositoryState::CherryPick | git2::RepositoryState::CherryPickSequence => {
1899            RepoState::CherryPick
1900        }
1901        git2::RepositoryState::Bisect => RepoState::Bisect,
1902        git2::RepositoryState::Rebase
1903        | git2::RepositoryState::RebaseInteractive
1904        | git2::RepositoryState::RebaseMerge => RepoState::Rebase,
1905        git2::RepositoryState::ApplyMailbox | git2::RepositoryState::ApplyMailboxOrRebase => {
1906            RepoState::ApplyMailbox
1907        }
1908    };
1909    s
1910}
1911
1912fn populate_worktree(repo: &Repository, s: &mut RepoSummary) {
1913    let mut opts = StatusOptions::new();
1914    opts.include_untracked(true).renames_head_to_index(true).show(StatusShow::IndexAndWorkdir);
1915    let Ok(statuses) = repo.statuses(Some(&mut opts)) else {
1916        return;
1917    };
1918    for entry in statuses.iter() {
1919        let flags = entry.status();
1920        if flags.is_conflicted() {
1921            s.conflicted += 1;
1922            continue;
1923        }
1924        if flags.is_wt_new() {
1925            s.untracked += 1;
1926        }
1927        if flags.is_wt_modified()
1928            || flags.is_wt_deleted()
1929            || flags.is_wt_renamed()
1930            || flags.is_wt_typechange()
1931        {
1932            s.modified += 1;
1933        }
1934        if flags.is_index_new()
1935            || flags.is_index_modified()
1936            || flags.is_index_deleted()
1937            || flags.is_index_renamed()
1938            || flags.is_index_typechange()
1939        {
1940            s.staged += 1;
1941        }
1942    }
1943}
1944
1945/// Compute commits ahead/behind the upstream branch. Silently leaves
1946/// both at 0 if HEAD is detached, the branch has no upstream configured,
1947/// or any libgit2 lookup fails — the card simply shows no ↑/↓ then.
1948fn populate_ahead_behind(repo: &Repository, s: &mut RepoSummary) {
1949    let Ok(head) = repo.head() else { return };
1950    let Some(local_oid) = head.target() else {
1951        return;
1952    };
1953    let Ok(head_name) = head.name() else { return };
1954    let Ok(upstream_buf) = repo.branch_upstream_name(head_name) else {
1955        return;
1956    };
1957    let Ok(upstream_name) = std::str::from_utf8(&upstream_buf) else {
1958        return;
1959    };
1960    let Ok(upstream_ref) = repo.find_reference(upstream_name) else {
1961        return;
1962    };
1963    let Some(upstream_oid) = upstream_ref.target() else {
1964        return;
1965    };
1966    if let Ok((ahead, behind)) = repo.graph_ahead_behind(local_oid, upstream_oid) {
1967        s.ahead = ahead;
1968        s.behind = behind;
1969    }
1970}
1971
1972/// `"origin/main"`-style short name for HEAD's upstream, or `None`.
1973fn upstream_short_name(repo: &Repository, head_name: &str) -> Option<String> {
1974    let buf = repo.branch_upstream_name(head_name).ok()?;
1975    let raw = std::str::from_utf8(&buf).ok()?;
1976    Some(raw.strip_prefix("refs/remotes/").unwrap_or(raw).to_string())
1977}
1978
1979/// Format a unix-epoch timestamp as a relative time string ("3 days ago").
1980pub fn format_relative_time(secs: i64) -> String {
1981    if secs <= 0 {
1982        return "unknown".to_string();
1983    }
1984    let then = UNIX_EPOCH + Duration::from_secs(secs as u64);
1985    let now = SystemTime::now();
1986    let Ok(elapsed) = now.duration_since(then) else {
1987        return "in the future".to_string();
1988    };
1989    let secs = elapsed.as_secs();
1990    let (n, unit) = if secs < 60 {
1991        (secs, "second")
1992    } else if secs < 3600 {
1993        (secs / 60, "minute")
1994    } else if secs < 86_400 {
1995        (secs / 3600, "hour")
1996    } else if secs < 86_400 * 30 {
1997        (secs / 86_400, "day")
1998    } else if secs < 86_400 * 365 {
1999        (secs / (86_400 * 30), "month")
2000    } else {
2001        (secs / (86_400 * 365), "year")
2002    };
2003    let plural = if n == 1 { "" } else { "s" };
2004    format!("{} {}{} ago", n, unit, plural)
2005}
2006
2007/// Format a unix-epoch timestamp as a UTC date string ("YYYY-MM-DD HH:MM:SS UTC").
2008fn format_utc_date(secs: i64) -> String {
2009    if secs <= 0 {
2010        return "unknown".to_string();
2011    }
2012    let seconds_in_day = 86400;
2013    let day_number = secs / seconds_in_day;
2014    let time_of_day = secs % seconds_in_day;
2015
2016    let mut hour = time_of_day / 3600;
2017    let mut minute = (time_of_day % 3600) / 60;
2018    let mut second = time_of_day % 60;
2019    if hour < 0 {
2020        hour += 24;
2021    }
2022    if minute < 0 {
2023        minute += 60;
2024    }
2025    if second < 0 {
2026        second += 60;
2027    }
2028
2029    // Howard Hinnant's civil date from epoch days algorithm
2030    let z = day_number + 719468;
2031    let era = (if z >= 0 { z } else { z - 146096 }) / 146097;
2032    let doe = (z - era * 146097) as u32;
2033    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
2034    let y = (yoe as i32) + (era as i32) * 400;
2035    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
2036    let mp = (5 * doy + 2) / 153;
2037    let d = doy - (153 * mp + 2) / 5 + 1;
2038    let m = if mp < 10 { mp + 3 } else { mp - 9 };
2039    let y = y + if m <= 2 { 1 } else { 0 };
2040
2041    format!("{:04}-{:02}-{:02} {:02}:{:02}:{:02} UTC", y, m, d, hour, minute, second)
2042}
2043
2044// ── Per-file diff (private) ────────────────────────────────────────────────
2045
2046fn get_file_diff_inner(
2047    repo_path: &Path,
2048    commit_oid: &str,
2049    file_path: &str,
2050) -> Option<Vec<DiffLine>> {
2051    let repo = Repository::open(repo_path).ok()?;
2052    let oid = git2::Oid::from_str(commit_oid).ok()?;
2053    let commit = repo.find_commit(oid).ok()?;
2054
2055    let commit_tree = commit.tree().ok()?;
2056    // For the initial commit, parent_tree is None; libgit2 treats it as empty.
2057    let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok());
2058
2059    let mut opts = git2::DiffOptions::new();
2060    opts.pathspec(file_path);
2061
2062    let diff =
2063        repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), Some(&mut opts)).ok()?;
2064
2065    collect_diff_lines(&diff)
2066}
2067
2068/// Diff a single file in the working tree.
2069///
2070/// `staged = true`:  HEAD-tree → index (what `git diff --cached` shows).
2071/// `staged = false`: index → working directory (what `git diff` shows).
2072fn get_worktree_diff_inner(
2073    repo_path: &Path,
2074    file_path: &str,
2075    staged: bool,
2076) -> Option<Vec<DiffLine>> {
2077    let repo = Repository::open(repo_path).ok()?;
2078    let mut opts = git2::DiffOptions::new();
2079    opts.pathspec(file_path);
2080    opts.include_untracked(true);
2081    opts.recurse_untracked_dirs(true);
2082    opts.show_untracked_content(true);
2083
2084    let diff = if staged {
2085        // Staged: diff HEAD tree (or empty tree for new repos) → index.
2086        let head_tree = repo.head().ok().and_then(|h| h.peel_to_tree().ok());
2087        repo.diff_tree_to_index(head_tree.as_ref(), None, Some(&mut opts)).ok()?
2088    } else {
2089        // Unstaged: diff index → working directory.
2090        repo.diff_index_to_workdir(None, Some(&mut opts)).ok()?
2091    };
2092
2093    collect_diff_lines(&diff)
2094}
2095
2096/// Walk a libgit2 `Diff` and collect coloured `DiffLine` values.
2097fn collect_diff_lines(diff: &git2::Diff<'_>) -> Option<Vec<DiffLine>> {
2098    let mut lines: Vec<DiffLine> = Vec::new();
2099    diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
2100        let kind = match line.origin() {
2101            '+' => DiffLineKind::Added,
2102            '-' => DiffLineKind::Removed,
2103            'H' => DiffLineKind::Header,
2104            ' ' => DiffLineKind::Context,
2105            _ => return true, // skip file-header meta lines
2106        };
2107        let content = String::from_utf8_lossy(line.content())
2108            .trim_end_matches('\n')
2109            .trim_end_matches('\r')
2110            .to_string();
2111        lines.push(DiffLine { kind, content });
2112        true
2113    })
2114    .ok()?;
2115    Some(lines)
2116}
2117
2118fn parse_graph_line(line: &str) -> GraphLine {
2119    if line.contains("__TWIG_SEP__") {
2120        let parts: Vec<&str> = line.split("__TWIG_SEP__").collect();
2121        if parts.len() >= 5 {
2122            let graph_and_hash = parts[0];
2123            let decoration = parts[1].trim().to_string();
2124            let summary = parts[2].trim().to_string();
2125            let author = parts[3].trim().to_string();
2126            let date = parts[4].trim().to_string();
2127            let signature_status =
2128                if parts.len() >= 6 { parts[5].trim().to_string() } else { "N".to_string() };
2129
2130            let char_count = graph_and_hash.chars().count();
2131            if char_count >= 40 {
2132                let graph: String = graph_and_hash.chars().take(char_count - 40).collect();
2133                let oid: String = graph_and_hash.chars().skip(char_count - 40).collect();
2134                GraphLine {
2135                    graph,
2136                    commit: Some(GraphCommit {
2137                        oid,
2138                        decoration,
2139                        summary,
2140                        author,
2141                        date,
2142                        signature_status,
2143                    }),
2144                }
2145            } else {
2146                GraphLine { graph: graph_and_hash.to_string(), commit: None }
2147            }
2148        } else {
2149            GraphLine { graph: line.to_string(), commit: None }
2150        }
2151    } else {
2152        GraphLine { graph: line.to_string(), commit: None }
2153    }
2154}
2155
2156#[allow(dead_code)]
2157fn collect_graph_lines(repo_path: &Path, graph_max_commits: usize) -> Vec<GraphLine> {
2158    let mut graph_lines = Vec::new();
2159    let format_str = "%H__TWIG_SEP__%d__TWIG_SEP__%s__TWIG_SEP__%an__TWIG_SEP__%ad__TWIG_SEP__%G?";
2160
2161    let mut args = vec![
2162        "log".to_string(),
2163        "--graph".to_string(),
2164        "--all".to_string(),
2165        "--date=relative".to_string(),
2166    ];
2167    if graph_max_commits > 0 {
2168        args.push(format!("--max-count={}", graph_max_commits));
2169    }
2170    args.push(format!("--pretty=format:{}", format_str));
2171    args.push("--color=never".to_string());
2172
2173    let output = std::process::Command::new("git")
2174        .env("GIT_TERMINAL_PROMPT", "0")
2175        .env("GIT_SSH_COMMAND", ssh_command_val())
2176        .args(&args)
2177        .current_dir(repo_path)
2178        .output();
2179
2180    if let Ok(out) = output {
2181        if out.status.success() {
2182            let stdout_str = String::from_utf8_lossy(&out.stdout);
2183            for line in stdout_str.lines() {
2184                graph_lines.push(parse_graph_line(line));
2185            }
2186        }
2187    }
2188    graph_lines
2189}
2190
2191pub fn checkout_local_branch(repo_path: &Path, branch_name: &str) -> Result<(), git2::Error> {
2192    let output = std::process::Command::new("git")
2193        .env("GIT_TERMINAL_PROMPT", "0")
2194        .env("GIT_SSH_COMMAND", ssh_command_val())
2195        .arg("checkout")
2196        .arg(branch_name)
2197        .current_dir(repo_path)
2198        .output()
2199        .map_err(|e| git2::Error::from_str(&e.to_string()))?;
2200
2201    if !output.status.success() {
2202        let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
2203        return Err(git2::Error::from_str(&err));
2204    }
2205    Ok(())
2206}
2207
2208pub fn checkout_remote_branch(
2209    repo_path: &Path,
2210    remote_branch_name: &str,
2211) -> Result<String, git2::Error> {
2212    let parts: Vec<&str> = remote_branch_name.splitn(2, '/').collect();
2213    if parts.len() < 2 {
2214        return Err(git2::Error::from_str("Invalid remote branch name"));
2215    }
2216    let local_name = parts[1];
2217
2218    let output = std::process::Command::new("git")
2219        .env("GIT_TERMINAL_PROMPT", "0")
2220        .env("GIT_SSH_COMMAND", ssh_command_val())
2221        .arg("checkout")
2222        .arg(local_name)
2223        .current_dir(repo_path)
2224        .output()
2225        .map_err(|e| git2::Error::from_str(&e.to_string()))?;
2226
2227    if output.status.success() {
2228        return Ok(format!("Switched to existing branch '{}'", local_name));
2229    }
2230
2231    let output = std::process::Command::new("git")
2232        .env("GIT_TERMINAL_PROMPT", "0")
2233        .env("GIT_SSH_COMMAND", ssh_command_val())
2234        .arg("checkout")
2235        .arg("--track")
2236        .arg(remote_branch_name)
2237        .current_dir(repo_path)
2238        .output()
2239        .map_err(|e| git2::Error::from_str(&e.to_string()))?;
2240
2241    if !output.status.success() {
2242        let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
2243        return Err(git2::Error::from_str(&err));
2244    }
2245
2246    Ok(format!("Created and switched to branch '{}' tracking '{}'", local_name, remote_branch_name))
2247}
2248
2249/// Creates a new local branch pointing at HEAD.
2250pub fn create_branch(repo_path: &Path, branch_name: &str) -> Result<(), git2::Error> {
2251    let repo = Repository::open(repo_path)?;
2252    let head = repo.head()?;
2253    let target_commit = head.peel_to_commit()?;
2254    repo.branch(branch_name, &target_commit, false)?;
2255    Ok(())
2256}
2257
2258/// Deletes a local branch.
2259pub fn delete_local_branch(repo_path: &Path, branch_name: &str) -> Result<(), git2::Error> {
2260    let repo = Repository::open(repo_path)?;
2261    let mut branch = repo.find_branch(branch_name, git2::BranchType::Local)?;
2262    branch.delete()?;
2263    Ok(())
2264}
2265
2266/// Deletes a remote-tracking branch locally.
2267pub fn delete_remote_branch(repo_path: &Path, branch_name: &str) -> Result<(), git2::Error> {
2268    let repo = Repository::open(repo_path)?;
2269    let mut branch = repo.find_branch(branch_name, git2::BranchType::Remote)?;
2270    branch.delete()?;
2271    Ok(())
2272}
2273
2274/// Creates a new lightweight tag pointing at the specified commit OID.
2275pub fn create_tag(
2276    repo_path: &Path,
2277    tag_name: &str,
2278    commit_oid_str: &str,
2279) -> Result<(), git2::Error> {
2280    let repo = Repository::open(repo_path)?;
2281    let oid = git2::Oid::from_str(commit_oid_str)?;
2282    let target_object = repo.find_object(oid, Some(git2::ObjectType::Commit))?;
2283    repo.tag_lightweight(tag_name, &target_object, false)?;
2284    Ok(())
2285}
2286
2287/// Deletes a local tag.
2288pub fn delete_tag(repo_path: &Path, tag_name: &str) -> Result<(), git2::Error> {
2289    let repo = Repository::open(repo_path)?;
2290    repo.tag_delete(tag_name)?;
2291    Ok(())
2292}
2293
2294/// Deletes a tag on the remote.
2295pub fn delete_remote_tag(
2296    repo_path: &Path,
2297    remote_name: &str,
2298    tag_name: &str,
2299) -> Result<(), Box<dyn std::error::Error>> {
2300    let safe_remote = safe_ref(remote_name)?;
2301    let safe_tag = safe_ref(tag_name)?;
2302    let output = git_command()
2303        .arg("push")
2304        .arg(safe_remote)
2305        .arg("--delete")
2306        .arg(safe_tag)
2307        .current_dir(repo_path)
2308        .output()?;
2309    if !output.status.success() {
2310        let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
2311        return Err(err.into());
2312    }
2313    Ok(())
2314}
2315
2316pub fn checkout_tag(repo_path: &Path, tag_name: &str) -> Result<(), git2::Error> {
2317    let safe_tag = safe_ref(tag_name).map_err(|e| git2::Error::from_str(&e))?;
2318    let output = git_command()
2319        .arg("checkout")
2320        .arg(safe_tag)
2321        .current_dir(repo_path)
2322        .output()
2323        .map_err(|e| git2::Error::from_str(&e.to_string()))?;
2324
2325    if !output.status.success() {
2326        let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
2327        return Err(git2::Error::from_str(&err));
2328    }
2329    Ok(())
2330}
2331
2332/// Helper to run `git ls-remote --tags` and return parsed tag information.
2333pub fn get_remote_tags(
2334    repo_path: &Path,
2335    remote_name: &str,
2336) -> Result<Vec<BranchInfo>, Box<dyn std::error::Error>> {
2337    let safe_remote = safe_ref(remote_name)?;
2338    let output = git_command()
2339        .arg("ls-remote")
2340        .arg("--tags")
2341        .arg(safe_remote)
2342        .current_dir(repo_path)
2343        .output()?;
2344
2345    if !output.status.success() {
2346        let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
2347        return Err(err.into());
2348    }
2349
2350    let stdout = String::from_utf8_lossy(&output.stdout);
2351    let repo = git2::Repository::open(repo_path)?;
2352    let mut tags_map = std::collections::HashMap::new();
2353
2354    for line in stdout.lines() {
2355        let parts: Vec<&str> = line.split_whitespace().collect();
2356        if parts.len() >= 2 {
2357            let sha = parts[0];
2358            let ref_name = parts[1];
2359            if ref_name.starts_with("refs/tags/") {
2360                let is_peeled = ref_name.ends_with("^{}");
2361                let clean_ref = if is_peeled {
2362                    safe_sha_slice(ref_name, ref_name.len().saturating_sub(3))
2363                } else {
2364                    ref_name
2365                };
2366                let tag_name = clean_ref.strip_prefix("refs/tags/").unwrap_or(clean_ref);
2367                let short_sha = safe_sha_slice(sha, 7);
2368
2369                // Try to resolve the summary locally
2370                let mut short_message = String::new();
2371                if let Ok(oid) = git2::Oid::from_str(sha) {
2372                    if let Ok(commit) = repo.find_commit(oid) {
2373                        if let Ok(Some(summary)) = commit.summary() {
2374                            short_message = summary.to_string();
2375                        }
2376                    }
2377                }
2378                if short_message.is_empty() {
2379                    short_message = "(not fetched)".to_string();
2380                }
2381
2382                let sanitized_tag = sanitize_text(tag_name);
2383                let sanitized_msg = sanitize_text(&short_message);
2384
2385                if is_peeled {
2386                    tags_map.insert(sanitized_tag, (short_sha.to_string(), sanitized_msg));
2387                } else {
2388                    tags_map
2389                        .entry(sanitized_tag)
2390                        .or_insert_with(|| (short_sha.to_string(), sanitized_msg));
2391                }
2392            }
2393        }
2394    }
2395
2396    let mut tags = Vec::new();
2397    for (name, (short_sha, short_message)) in tags_map {
2398        tags.push(BranchInfo { name, is_head: false, short_sha, short_message });
2399    }
2400    tags.sort_by(|a, b| b.name.cmp(&a.name));
2401    Ok(tags)
2402}
2403
2404pub fn serialize_tags(tags: &[BranchInfo]) -> String {
2405    let mut s = String::new();
2406    for tag in tags {
2407        s.push_str(&format!("{}|{}|{}\n", tag.name, tag.short_sha, tag.short_message));
2408    }
2409    s
2410}
2411
2412pub fn deserialize_tags(s: &str) -> Vec<BranchInfo> {
2413    let mut tags = Vec::new();
2414    for line in s.lines() {
2415        let parts: Vec<&str> = line.split('|').collect();
2416        if parts.len() >= 3 {
2417            tags.push(BranchInfo {
2418                name: parts[0].to_string(),
2419                is_head: false,
2420                short_sha: parts[1].to_string(),
2421                short_message: parts[2].to_string(),
2422            });
2423        }
2424    }
2425    tags
2426}
2427
2428pub fn delete_stash(repo_path: &Path, index: usize) -> Result<(), git2::Error> {
2429    let mut repo = Repository::open(repo_path)?;
2430    repo.stash_drop(index)?;
2431    Ok(())
2432}
2433
2434pub fn apply_stash(repo_path: &Path, index: usize) -> Result<(), String> {
2435    let stash_ref = format!("stash@{{{}}}", index);
2436    let output = std::process::Command::new("git")
2437        .env("GIT_TERMINAL_PROMPT", "0")
2438        .env("GIT_SSH_COMMAND", ssh_command_val())
2439        .arg("stash")
2440        .arg("apply")
2441        .arg(&stash_ref)
2442        .current_dir(repo_path)
2443        .output()
2444        .map_err(|e| e.to_string())?;
2445
2446    if !output.status.success() {
2447        let err_msg = String::from_utf8_lossy(&output.stderr).trim().to_string();
2448        return Err(err_msg);
2449    }
2450    Ok(())
2451}
2452
2453pub fn save_stash(
2454    repo_path: &Path,
2455    message: &str,
2456    include_untracked: bool,
2457    keep_index: bool,
2458) -> Result<(), String> {
2459    let mut cmd = std::process::Command::new("git");
2460    cmd.env("GIT_TERMINAL_PROMPT", "0")
2461        .env("GIT_SSH_COMMAND", ssh_command_val())
2462        .arg("stash")
2463        .arg("push");
2464
2465    if include_untracked {
2466        cmd.arg("--include-untracked");
2467    }
2468    if keep_index {
2469        cmd.arg("--keep-index");
2470    }
2471
2472    if !message.is_empty() {
2473        cmd.arg("-m").arg(message);
2474    }
2475
2476    let output = cmd.current_dir(repo_path).output().map_err(|e| e.to_string())?;
2477
2478    if !output.status.success() {
2479        let err_msg = String::from_utf8_lossy(&output.stderr).trim().to_string();
2480        return Err(err_msg);
2481    }
2482    Ok(())
2483}
2484
2485pub fn get_latest_change_time(item: &str) -> u64 {
2486    let path = expand_tilde(item);
2487    if !path.exists() {
2488        return 0;
2489    }
2490
2491    if path.join(".git").exists() {
2492        if let Ok(repo) = Repository::open(&path) {
2493            if let Ok(head) = repo.head() {
2494                if let Ok(commit) = head.peel_to_commit() {
2495                    return commit.time().seconds() as u64;
2496                }
2497            }
2498        }
2499    }
2500
2501    if let Ok(meta) = std::fs::metadata(&path) {
2502        if let Ok(modified) = meta.modified() {
2503            if let Ok(duration) = modified.duration_since(std::time::UNIX_EPOCH) {
2504                return duration.as_secs();
2505            }
2506        }
2507    }
2508    0
2509}
2510
2511pub fn get_last_commit_message(repo_path: &Path) -> Option<String> {
2512    if let Ok(repo) = Repository::open(repo_path) {
2513        if let Ok(head) = repo.head() {
2514            if let Ok(commit) = head.peel_to_commit() {
2515                if let Ok(msg) = commit.message() {
2516                    return Some(msg.to_string());
2517                }
2518            }
2519        }
2520    }
2521    None
2522}
2523
2524pub fn commit_amend(repo_path: &Path, message: &str) -> Result<(), String> {
2525    let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
2526    let head = repo.head().map_err(|e| format!("No HEAD commit to amend: {}", e))?;
2527    let head_commit = head.peel_to_commit().map_err(|e| e.to_string())?;
2528
2529    let mut index = repo.index().map_err(|e| e.to_string())?;
2530    let tree_id = index.write_tree().map_err(|e| e.to_string())?;
2531    let tree = repo.find_tree(tree_id).map_err(|e| e.to_string())?;
2532
2533    let signature = repo
2534        .signature()
2535        .map_err(|e| format!("Failed to get signature. Check user.name/email config: {}", e))?;
2536
2537    head_commit
2538        .amend(Some("HEAD"), None, Some(&signature), None, Some(message), Some(&tree))
2539        .map_err(|e| e.to_string())?;
2540
2541    Ok(())
2542}
2543
2544// ── Merge Conflict Helpers ──────────────────────────────────────────────────
2545
2546/// Returns `true` when `.git/MERGE_HEAD` exists — i.e. a merge is in progress.
2547/// Cheap file-existence check, no libgit2 required.
2548pub fn is_merging(repo_path: &Path) -> bool {
2549    repo_path.join(".git/MERGE_HEAD").exists()
2550}
2551
2552/// Returns the conflict-marker diff for a conflicted file by parsing the file on disk.
2553/// Colorizes conflict blocks using DiffLineKind variants.
2554pub fn get_conflict_markers_diff(repo_path: &Path, file_path: &str) -> Vec<DiffLine> {
2555    let full_path = repo_path.join(file_path);
2556    let content = match std::fs::read_to_string(&full_path) {
2557        Ok(s) => s,
2558        Err(_) => return Vec::new(),
2559    };
2560
2561    let mut lines = Vec::new();
2562    let mut in_ours = false;
2563    let mut in_theirs = false;
2564
2565    for line in content.lines() {
2566        if line.starts_with("<<<<<<<") {
2567            in_ours = true;
2568            in_theirs = false;
2569            lines.push(DiffLine {
2570                kind: DiffLineKind::ConflictSeparator,
2571                content: line.to_string(),
2572            });
2573        } else if line.starts_with("=======") {
2574            in_ours = false;
2575            in_theirs = true;
2576            lines.push(DiffLine {
2577                kind: DiffLineKind::ConflictSeparator,
2578                content: line.to_string(),
2579            });
2580        } else if line.starts_with(">>>>>>>") {
2581            in_ours = false;
2582            in_theirs = false;
2583            lines.push(DiffLine {
2584                kind: DiffLineKind::ConflictSeparator,
2585                content: line.to_string(),
2586            });
2587        } else if in_ours {
2588            lines.push(DiffLine { kind: DiffLineKind::ConflictOurs, content: line.to_string() });
2589        } else if in_theirs {
2590            lines.push(DiffLine { kind: DiffLineKind::ConflictTheirs, content: line.to_string() });
2591        } else {
2592            lines.push(DiffLine { kind: DiffLineKind::Context, content: line.to_string() });
2593        }
2594    }
2595    lines
2596}
2597
2598/// Accept the OURS (HEAD) version of a conflicted file.
2599/// Equivalent to: git checkout --ours <file> && git add <file>
2600pub fn resolve_ours(repo_path: &Path, file_path: &str) -> Result<(), String> {
2601    let output1 = std::process::Command::new("git")
2602        .env("GIT_TERMINAL_PROMPT", "0")
2603        .env("GIT_SSH_COMMAND", ssh_command_val())
2604        .args(["checkout", "--ours", file_path])
2605        .current_dir(repo_path)
2606        .output()
2607        .map_err(|e| e.to_string())?;
2608    if !output1.status.success() {
2609        return Err(String::from_utf8_lossy(&output1.stderr).to_string());
2610    }
2611    stage_file(repo_path, file_path)?;
2612    Ok(())
2613}
2614
2615/// Accept the THEIRS (incoming) version of a conflicted file.
2616/// Equivalent to: git checkout --theirs <file> && git add <file>
2617pub fn resolve_theirs(repo_path: &Path, file_path: &str) -> Result<(), String> {
2618    let output1 = std::process::Command::new("git")
2619        .env("GIT_TERMINAL_PROMPT", "0")
2620        .env("GIT_SSH_COMMAND", ssh_command_val())
2621        .args(["checkout", "--theirs", file_path])
2622        .current_dir(repo_path)
2623        .output()
2624        .map_err(|e| e.to_string())?;
2625    if !output1.status.success() {
2626        return Err(String::from_utf8_lossy(&output1.stderr).to_string());
2627    }
2628    stage_file(repo_path, file_path)?;
2629    Ok(())
2630}
2631
2632/// Mark the file as resolved (stage it) after manual edits.
2633pub fn mark_resolved(repo_path: &Path, file_path: &str) -> Result<(), String> {
2634    stage_file(repo_path, file_path)
2635}
2636
2637/// Resolve a specific conflict hunk inside a file (Ours vs Theirs).
2638/// Replaces the hunk at index `hunk_idx` in the file on disk.
2639/// If no more conflicts remain in the file, it automatically stages the file.
2640pub fn resolve_conflict_hunk(
2641    repo_path: &Path,
2642    file_path: &str,
2643    hunk_idx: usize,
2644    accept_ours: bool,
2645) -> Result<(), String> {
2646    let full_path = repo_path.join(file_path);
2647    let content = std::fs::read_to_string(&full_path).map_err(|e| e.to_string())?;
2648
2649    let mut new_lines = Vec::new();
2650    let mut lines_iter = content.lines().peekable();
2651    let mut current_hunk_idx = 0;
2652
2653    while let Some(line) = lines_iter.next() {
2654        if line.starts_with("<<<<<<<") {
2655            let mut ours_block = Vec::new();
2656            let mut theirs_block = Vec::new();
2657
2658            // Read ours block (until =======)
2659            let mut found_separator = false;
2660            while let Some(&next_line) = lines_iter.peek() {
2661                if next_line.starts_with("=======") {
2662                    lines_iter.next(); // consume =======
2663                    found_separator = true;
2664                    break;
2665                }
2666                if let Some(line) = lines_iter.next() {
2667                    ours_block.push(line.to_string());
2668                }
2669            }
2670
2671            // Read theirs block (until >>>>>>>)
2672            let mut found_end = false;
2673            let mut end_line_marker = ">>>>>>>".to_string();
2674            while let Some(&next_line) = lines_iter.peek() {
2675                if next_line.starts_with(">>>>>>>") {
2676                    if let Some(marker) = lines_iter.next() {
2677                        end_line_marker = marker.to_string(); // consume >>>>>>>
2678                    }
2679                    found_end = true;
2680                    break;
2681                }
2682                if let Some(line) = lines_iter.next() {
2683                    theirs_block.push(line.to_string());
2684                }
2685            }
2686
2687            if current_hunk_idx == hunk_idx {
2688                if accept_ours {
2689                    new_lines.extend(ours_block);
2690                } else {
2691                    new_lines.extend(theirs_block);
2692                }
2693            } else {
2694                new_lines.push(line.to_string());
2695                new_lines.extend(ours_block);
2696                if found_separator {
2697                    new_lines.push("=======".to_string());
2698                }
2699                new_lines.extend(theirs_block);
2700                if found_end {
2701                    new_lines.push(end_line_marker);
2702                }
2703            }
2704
2705            current_hunk_idx += 1;
2706        } else {
2707            new_lines.push(line.to_string());
2708        }
2709    }
2710
2711    let mut new_content = new_lines.join("\n");
2712    if content.ends_with('\n') && !new_content.ends_with('\n') {
2713        new_content.push('\n');
2714    }
2715    std::fs::write(&full_path, new_content).map_err(|e| e.to_string())?;
2716
2717    // Check if any conflict markers remain in the file
2718    let updated_content = std::fs::read_to_string(&full_path).map_err(|e| e.to_string())?;
2719    let has_conflict_markers = updated_content
2720        .lines()
2721        .any(|l| l.starts_with("<<<<<<<") || l.starts_with("=======") || l.starts_with(">>>>>>>"));
2722
2723    if !has_conflict_markers {
2724        stage_file(repo_path, file_path)?;
2725    }
2726
2727    Ok(())
2728}
2729
2730/// Abort the in-progress merge.
2731pub fn abort_merge(repo_path: &Path) -> Result<(), String> {
2732    let output = std::process::Command::new("git")
2733        .env("GIT_TERMINAL_PROMPT", "0")
2734        .env("GIT_SSH_COMMAND", ssh_command_val())
2735        .args(["merge", "--abort"])
2736        .current_dir(repo_path)
2737        .output()
2738        .map_err(|e| e.to_string())?;
2739    if !output.status.success() {
2740        return Err(String::from_utf8_lossy(&output.stderr).to_string());
2741    }
2742    Ok(())
2743}
2744
2745/// Continue the merge after conflicts are resolved.
2746pub fn continue_merge(repo_path: &Path) -> Result<(), String> {
2747    let output = std::process::Command::new("git")
2748        .env("GIT_TERMINAL_PROMPT", "0")
2749        .env("GIT_SSH_COMMAND", ssh_command_val())
2750        .args(["merge", "--continue"])
2751        .env("GIT_EDITOR", "true")
2752        .current_dir(repo_path)
2753        .output()
2754        .map_err(|e| e.to_string())?;
2755    if !output.status.success() {
2756        return Err(String::from_utf8_lossy(&output.stderr).to_string());
2757    }
2758    Ok(())
2759}
2760
2761/// Returns the upstream tracking remote name for a branch, if configured.
2762pub fn get_branch_upstream_remote(repo_path: &Path, branch_name: &str) -> Option<String> {
2763    let repo = Repository::open(repo_path).ok()?;
2764    let branch = repo.find_branch(branch_name, git2::BranchType::Local).ok()?;
2765    let upstream = branch.upstream().ok()?;
2766    let upstream_ref = upstream.get().name().ok()?;
2767    let remote_buf = repo.branch_upstream_remote(upstream_ref).ok()?;
2768    remote_buf.as_str().ok().map(|s| s.to_string())
2769}
2770
2771/// Returns whether the specified branch has a configured upstream tracking branch.
2772pub fn has_upstream_remote(repo_path: &Path, branch_name: &str) -> bool {
2773    get_branch_upstream_remote(repo_path, branch_name).is_some()
2774}
2775
2776/// Finds the remote target for pushing a branch. Returns `(remote_name, set_upstream)`.
2777pub fn get_branch_push_target(repo_path: &Path, branch_name: &str) -> Option<(String, bool)> {
2778    let repo = Repository::open(repo_path).ok()?;
2779    let branch = repo.find_branch(branch_name, git2::BranchType::Local).ok()?;
2780    if let Ok(upstream) = branch.upstream() {
2781        if let Ok(upstream_ref) = upstream.get().name() {
2782            if let Ok(remote_buf) = repo.branch_upstream_remote(upstream_ref) {
2783                if let Ok(name) = remote_buf.as_str() {
2784                    return Some((name.to_string(), false));
2785                }
2786            }
2787        }
2788    }
2789    let remotes = repo.remotes().ok()?;
2790    let first_remote = remotes.iter().next()?.ok()??.to_string();
2791    Some((first_remote, true))
2792}
2793
2794/// Returns whether the specified commit OID has no parents (i.e. it is the root commit).
2795pub fn is_root_commit(repo_path: &Path, commit_oid: &str) -> bool {
2796    if let Ok(repo) = Repository::open(repo_path) {
2797        if let Ok(oid) = git2::Oid::from_str(commit_oid) {
2798            if let Ok(commit) = repo.find_commit(oid) {
2799                return commit.parent_count() == 0;
2800            }
2801        }
2802    }
2803    false
2804}
2805
2806pub fn load_tab_worktrees(repo_path: &Path) -> Result<Vec<WorktreeInfo>, String> {
2807    let repo = Repository::open(repo_path).map_err(|e| e.to_string())?;
2808    let mut worktrees_list = Vec::new();
2809    if let Ok(worktree_names) = repo.worktrees() {
2810        for name in worktree_names.iter() {
2811            let Ok(Some(wt_name)) = name else { continue };
2812            if let Ok(wt) = repo.find_worktree(wt_name) {
2813                let path = wt.path().to_path_buf();
2814                let mut branch = None;
2815                if let Ok(wt_repo) = Repository::open(&path) {
2816                    if let Ok(head) = wt_repo.head() {
2817                        branch = head.shorthand().map(String::from).ok();
2818                    }
2819                }
2820
2821                let mut is_locked = false;
2822                let mut lock_reason = None;
2823                if let Ok(git2::WorktreeLockStatus::Locked(reason_opt)) = wt.is_locked() {
2824                    is_locked = true;
2825                    if let Some(reason) = reason_opt {
2826                        if !reason.is_empty() {
2827                            lock_reason = Some(reason);
2828                        }
2829                    }
2830                }
2831
2832                worktrees_list.push(WorktreeInfo {
2833                    name: wt_name.to_string(),
2834                    path,
2835                    branch,
2836                    is_locked,
2837                    lock_reason,
2838                });
2839            }
2840        }
2841    }
2842    Ok(worktrees_list)
2843}
2844
2845pub fn worktree_add(repo_path: &Path, branch: &str, wt_path: &Path) -> Result<(), String> {
2846    let output = std::process::Command::new("git")
2847        .arg("worktree")
2848        .arg("add")
2849        .arg(wt_path)
2850        .arg(branch)
2851        .current_dir(repo_path)
2852        .output()
2853        .map_err(|e| e.to_string())?;
2854
2855    if !output.status.success() {
2856        return Err(String::from_utf8_lossy(&output.stderr).to_string());
2857    }
2858    Ok(())
2859}
2860
2861pub fn worktree_lock(repo_path: &Path, name: &str, reason: &str) -> Result<(), String> {
2862    let output = std::process::Command::new("git")
2863        .arg("worktree")
2864        .arg("lock")
2865        .arg("--reason")
2866        .arg(reason)
2867        .arg(name)
2868        .current_dir(repo_path)
2869        .output()
2870        .map_err(|e| e.to_string())?;
2871
2872    if !output.status.success() {
2873        return Err(String::from_utf8_lossy(&output.stderr).to_string());
2874    }
2875    Ok(())
2876}
2877
2878pub fn worktree_unlock(repo_path: &Path, name: &str) -> Result<(), String> {
2879    let output = std::process::Command::new("git")
2880        .arg("worktree")
2881        .arg("unlock")
2882        .arg(name)
2883        .current_dir(repo_path)
2884        .output()
2885        .map_err(|e| e.to_string())?;
2886
2887    if !output.status.success() {
2888        return Err(String::from_utf8_lossy(&output.stderr).to_string());
2889    }
2890    Ok(())
2891}
2892
2893pub fn worktree_remove(repo_path: &Path, name: &str, force: bool) -> Result<(), String> {
2894    let mut cmd = std::process::Command::new("git");
2895    cmd.arg("worktree").arg("remove");
2896    if force {
2897        cmd.arg("--force");
2898    }
2899    let output = cmd.arg(name).current_dir(repo_path).output().map_err(|e| e.to_string())?;
2900
2901    if !output.status.success() {
2902        return Err(String::from_utf8_lossy(&output.stderr).to_string());
2903    }
2904    Ok(())
2905}
2906
2907pub fn worktree_prune(repo_path: &Path) -> Result<(), String> {
2908    let output = std::process::Command::new("git")
2909        .arg("worktree")
2910        .arg("prune")
2911        .current_dir(repo_path)
2912        .output()
2913        .map_err(|e| e.to_string())?;
2914
2915    if !output.status.success() {
2916        return Err(String::from_utf8_lossy(&output.stderr).to_string());
2917    }
2918    Ok(())
2919}
2920
2921#[cfg(test)]
2922#[allow(clippy::unwrap_used, clippy::panic)]
2923mod tests {
2924    use super::*;
2925    use std::fs::File;
2926    use std::io::Write;
2927
2928    #[test]
2929    fn test_commit_amend() {
2930        let mut temp_path = std::env::temp_dir();
2931        temp_path.push(format!(
2932            "twig_test_{}",
2933            std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
2934        ));
2935        std::fs::create_dir_all(&temp_path).unwrap();
2936
2937        // Init repo
2938        let repo = Repository::init(&temp_path).unwrap();
2939
2940        // Configure author
2941        let mut config = repo.config().unwrap();
2942        config.set_str("user.name", "Test User").unwrap();
2943        config.set_str("user.email", "test@example.com").unwrap();
2944
2945        // Create initial file
2946        let file_path = temp_path.join("test.txt");
2947        let mut file = File::create(&file_path).unwrap();
2948        writeln!(file, "initial content").unwrap();
2949
2950        // Stage and commit initial
2951        stage_file(&temp_path, "test.txt").unwrap();
2952        commit_changes(&temp_path, "initial commit").unwrap();
2953
2954        // Verify message
2955        let msg = get_last_commit_message(&temp_path).unwrap();
2956        assert_eq!(msg, "initial commit");
2957
2958        // Amend the commit message
2959        commit_amend(&temp_path, "amended commit").unwrap();
2960
2961        // Verify amended message
2962        let amended_msg = get_last_commit_message(&temp_path).unwrap();
2963        assert_eq!(amended_msg, "amended commit");
2964
2965        // Clean up
2966        let _ = std::fs::remove_dir_all(&temp_path);
2967    }
2968
2969    #[test]
2970    fn test_commit_signatures_collection() {
2971        let mut temp_path = std::env::temp_dir();
2972        temp_path.push(format!(
2973            "twig_test_sig_{}",
2974            std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
2975        ));
2976        std::fs::create_dir_all(&temp_path).unwrap();
2977
2978        // Init repo
2979        let repo = Repository::init(&temp_path).unwrap();
2980
2981        // Configure author
2982        let mut config = repo.config().unwrap();
2983        config.set_str("user.name", "Test User").unwrap();
2984        config.set_str("user.email", "test@example.com").unwrap();
2985
2986        // Create initial file
2987        let file_path = temp_path.join("test.txt");
2988        let mut file = File::create(&file_path).unwrap();
2989        writeln!(file, "initial content").unwrap();
2990
2991        // Stage and commit initial
2992        stage_file(&temp_path, "test.txt").unwrap();
2993        commit_changes(&temp_path, "initial commit").unwrap();
2994
2995        // 1. Test collect_signatures
2996        let sigs = collect_signatures(&temp_path, 0);
2997        assert_eq!(sigs.len(), 1);
2998        let head_oid = repo.head().unwrap().target().unwrap().to_string();
2999        let sig_status = sigs.get(&head_oid).unwrap();
3000        assert_eq!(sig_status, "N");
3001
3002        // 2. Test collect_commits (files should be empty by default)
3003        let commits = collect_commits(&repo, 0, &temp_path, true).unwrap();
3004        assert_eq!(commits.len(), 1);
3005        assert_eq!(commits[0].signature_status, "N");
3006        assert!(commits[0].files.is_empty());
3007
3008        // Test get_commit_files (lazy loading)
3009        let files = get_commit_files(&temp_path, &commits[0].oid).unwrap();
3010        assert_eq!(files.len(), 1);
3011        assert_eq!(files[0].path, "test.txt");
3012        assert_eq!(files[0].label, "N");
3013
3014        // 3. Test collect_graph_lines
3015        let graph = collect_graph_lines(&temp_path, 1000);
3016        assert_eq!(graph.len(), 1);
3017        assert!(graph[0].commit.is_some());
3018        assert_eq!(graph[0].commit.as_ref().unwrap().signature_status, "N");
3019
3020        // Clean up
3021        let _ = std::fs::remove_dir_all(&temp_path);
3022    }
3023
3024    #[test]
3025    fn test_ref_map_cache_behavior() {
3026        let temp_dir = std::env::temp_dir();
3027        let repo_path = temp_dir.join("test_ref_map_repo");
3028        let _ = std::fs::remove_dir_all(&repo_path);
3029        std::fs::create_dir_all(&repo_path).unwrap();
3030
3031        let repo = Repository::init(&repo_path).unwrap();
3032
3033        // 1. First fetch (rebuilds and caches)
3034        let map1 = get_cached_ref_map(&repo, &repo_path);
3035
3036        // 2. Second fetch (returns cached map)
3037        let map2 = get_cached_ref_map(&repo, &repo_path);
3038        assert_eq!(map1.len(), map2.len());
3039
3040        // 3. Invalidate cache
3041        invalidate_ref_map_cache(&repo_path);
3042
3043        // Clean up
3044        let _ = std::fs::remove_dir_all(&repo_path);
3045    }
3046
3047    #[test]
3048    fn test_get_latest_change_time() {
3049        let mut temp_path = std::env::temp_dir();
3050        temp_path.push(format!(
3051            "twig_test_{}",
3052            std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3053        ));
3054        std::fs::create_dir_all(&temp_path).unwrap();
3055
3056        let change_time = get_latest_change_time(temp_path.to_str().unwrap());
3057        assert!(change_time > 0);
3058
3059        let _ = std::fs::remove_dir_all(&temp_path);
3060    }
3061
3062    #[test]
3063    fn test_committer_stats() {
3064        let mut temp_path = std::env::temp_dir();
3065        temp_path.push(format!(
3066            "twig_test_{}",
3067            std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3068        ));
3069        std::fs::create_dir_all(&temp_path).unwrap();
3070
3071        // Init repo
3072        let repo = Repository::init(&temp_path).unwrap();
3073
3074        // Configure author
3075        let mut config = repo.config().unwrap();
3076        config.set_str("user.name", "Test User").unwrap();
3077        config.set_str("user.email", "test@example.com").unwrap();
3078
3079        // Create initial file
3080        let file_path = temp_path.join("test.txt");
3081        let mut file = File::create(&file_path).unwrap();
3082        writeln!(file, "initial content").unwrap();
3083
3084        // Stage and commit initial
3085        stage_file(&temp_path, "test.txt").unwrap();
3086        commit_changes(&temp_path, "initial commit").unwrap();
3087
3088        // Collect stats
3089        let (stats, limit_reached) = collect_committer_stats(&repo, 10).unwrap();
3090        assert_eq!(stats.len(), 1);
3091        assert_eq!(stats[0].name, "Test User");
3092        assert_eq!(stats[0].email, "test@example.com");
3093        assert_eq!(stats[0].count, 1);
3094        assert!(!limit_reached);
3095
3096        // Clean up
3097        let _ = std::fs::remove_dir_all(&temp_path);
3098    }
3099
3100    #[test]
3101    fn test_untracked_files_in_unstaged() {
3102        let mut temp_path = std::env::temp_dir();
3103        temp_path.push(format!(
3104            "twig_test_{}",
3105            std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3106        ));
3107        std::fs::create_dir_all(&temp_path).unwrap();
3108
3109        // Init repo
3110        let _repo = Repository::init(&temp_path).unwrap();
3111
3112        // Create an untracked file
3113        let file_path = temp_path.join("untracked.txt");
3114        let mut file = File::create(&file_path).unwrap();
3115        writeln!(file, "hello untracked").unwrap();
3116
3117        // Create an untracked directory and a file inside it
3118        let untracked_dir = temp_path.join("untracked_dir");
3119        std::fs::create_dir_all(&untracked_dir).unwrap();
3120        let nested_file_path = untracked_dir.join("nested.txt");
3121        std::fs::write(&nested_file_path, "nested untracked file").unwrap();
3122
3123        // Inspect detail
3124        let detail = inspect_detail(temp_path.to_str().unwrap(), 0, 1000, false);
3125        match detail {
3126            ItemDetail::Repo { info, .. } => {
3127                // Verify no folders are in the unstaged/untracked list
3128                let unstaged_paths: Vec<String> =
3129                    info.changes.unstaged.iter().map(|f| f.path.clone()).collect();
3130                let untracked_paths: Vec<String> =
3131                    info.changes.untracked.iter().map(|f| f.path.clone()).collect();
3132
3133                // Folder itself should NOT be listed
3134                assert!(!unstaged_paths.contains(&"untracked_dir".to_string()));
3135                assert!(!unstaged_paths.contains(&"untracked_dir/".to_string()));
3136                assert!(!untracked_paths.contains(&"untracked_dir".to_string()));
3137                assert!(!untracked_paths.contains(&"untracked_dir/".to_string()));
3138
3139                // Untracked files (both root and nested) should be listed
3140                assert!(unstaged_paths.contains(&"untracked.txt".to_string()));
3141                assert!(unstaged_paths.contains(&"untracked_dir/nested.txt".to_string()));
3142                assert!(untracked_paths.contains(&"untracked.txt".to_string()));
3143                assert!(untracked_paths.contains(&"untracked_dir/nested.txt".to_string()));
3144            }
3145            _ => panic!("Expected ItemDetail::Repo"),
3146        }
3147
3148        // Clean up
3149        let _ = std::fs::remove_dir_all(&temp_path);
3150    }
3151
3152    #[test]
3153    fn test_stage_new_and_deleted_files() {
3154        let mut temp_path = std::env::temp_dir();
3155        temp_path.push(format!(
3156            "twig_test_{}",
3157            std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3158        ));
3159        std::fs::create_dir_all(&temp_path).unwrap();
3160
3161        // Init repo
3162        let repo = Repository::init(&temp_path).unwrap();
3163
3164        // Configure author
3165        let mut config = repo.config().unwrap();
3166        config.set_str("user.name", "Test User").unwrap();
3167        config.set_str("user.email", "test@example.com").unwrap();
3168
3169        // Create initial file & commit
3170        let init_file = temp_path.join("init.txt");
3171        std::fs::write(&init_file, "initial").unwrap();
3172        stage_file(&temp_path, "init.txt").unwrap();
3173        commit_changes(&temp_path, "initial commit").unwrap();
3174
3175        // 1. Create a new file (untracked)
3176        let untracked_file = temp_path.join("untracked.txt");
3177        std::fs::write(&untracked_file, "new file content").unwrap();
3178
3179        // Try staging untracked file
3180        stage_file(&temp_path, "untracked.txt").unwrap();
3181
3182        // 2. Delete the initial file
3183        std::fs::remove_file(&init_file).unwrap();
3184
3185        // Try staging deleted file
3186        stage_file(&temp_path, "init.txt").unwrap();
3187
3188        // Check status of repo
3189        let detail = inspect_detail(temp_path.to_str().unwrap(), 0, 1000, false);
3190        match detail {
3191            ItemDetail::Repo { info, .. } => {
3192                // Both should be in staged changes
3193                assert_eq!(info.changes.staged.len(), 2);
3194                let paths: Vec<String> =
3195                    info.changes.staged.iter().map(|f| f.path.clone()).collect();
3196                assert!(paths.contains(&"untracked.txt".to_string()));
3197                assert!(paths.contains(&"init.txt".to_string()));
3198            }
3199            _ => panic!("Expected ItemDetail::Repo"),
3200        }
3201
3202        // Clean up
3203        let _ = std::fs::remove_dir_all(&temp_path);
3204    }
3205
3206    #[test]
3207    fn test_discard_file_changes_all_cases() {
3208        let mut temp_path = std::env::temp_dir();
3209        temp_path.push(format!(
3210            "twig_test_{}",
3211            std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3212        ));
3213        std::fs::create_dir_all(&temp_path).unwrap();
3214
3215        // Init repo
3216        let repo = Repository::init(&temp_path).unwrap();
3217
3218        // Configure author
3219        let mut config = repo.config().unwrap();
3220        config.set_str("user.name", "Test User").unwrap();
3221        config.set_str("user.email", "test@example.com").unwrap();
3222
3223        // Create and commit initial files
3224        let file_tracked = temp_path.join("tracked.txt");
3225        std::fs::write(&file_tracked, "original content\n").unwrap();
3226        stage_file(&temp_path, "tracked.txt").unwrap();
3227        commit_changes(&temp_path, "initial commit").unwrap();
3228
3229        // Case 1: Untracked file
3230        let file_untracked = temp_path.join("untracked.txt");
3231        std::fs::write(&file_untracked, "new untracked file\n").unwrap();
3232        assert!(file_untracked.exists());
3233        discard_file_changes(&temp_path, "untracked.txt", false).unwrap();
3234        assert!(!file_untracked.exists());
3235
3236        // Case 2: Tracked file with unstaged modification
3237        std::fs::write(&file_tracked, "unstaged modifications\n").unwrap();
3238        discard_file_changes(&temp_path, "tracked.txt", false).unwrap();
3239        assert_eq!(std::fs::read_to_string(&file_tracked).unwrap(), "original content\n");
3240
3241        // Case 3: Tracked file with staged modification
3242        std::fs::write(&file_tracked, "staged modifications\n").unwrap();
3243        stage_file(&temp_path, "tracked.txt").unwrap();
3244        // verify it's staged
3245        let detail = inspect_detail(temp_path.to_str().unwrap(), 0, 1000, false);
3246        match detail {
3247            ItemDetail::Repo { info, .. } => {
3248                assert!(!info.changes.staged.is_empty());
3249            }
3250            _ => panic!("Expected ItemDetail::Repo"),
3251        }
3252        discard_file_changes(&temp_path, "tracked.txt", true).unwrap();
3253        assert_eq!(std::fs::read_to_string(&file_tracked).unwrap(), "original content\n");
3254        // verify it's no longer staged/unstaged (it's clean)
3255        let detail = inspect_detail(temp_path.to_str().unwrap(), 0, 1000, false);
3256        match detail {
3257            ItemDetail::Repo { info, .. } => {
3258                assert!(info.changes.staged.is_empty());
3259                assert!(info.changes.unstaged.is_empty());
3260            }
3261            _ => panic!("Expected ItemDetail::Repo"),
3262        }
3263
3264        // Case 4: Tracked deleted file
3265        std::fs::remove_file(&file_tracked).unwrap();
3266        assert!(!file_tracked.exists());
3267        discard_file_changes(&temp_path, "tracked.txt", false).unwrap();
3268        assert!(file_tracked.exists());
3269        assert_eq!(std::fs::read_to_string(&file_tracked).unwrap(), "original content\n");
3270
3271        // Clean up
3272        let _ = std::fs::remove_dir_all(&temp_path);
3273    }
3274
3275    #[test]
3276    #[cfg_attr(target_os = "windows", ignore = "CRLF patch issues on Windows")]
3277    fn test_stage_unstage_by_hunk() {
3278        let mut temp_path = std::env::temp_dir();
3279        temp_path.push(format!(
3280            "twig_test_{}",
3281            std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3282        ));
3283        std::fs::create_dir_all(&temp_path).unwrap();
3284
3285        // Init repo
3286        let repo = Repository::init(&temp_path).unwrap();
3287
3288        // Configure author
3289        let mut config = repo.config().unwrap();
3290        config.set_str("user.name", "Test User").unwrap();
3291        config.set_str("user.email", "test@example.com").unwrap();
3292
3293        // Create initial file with multiple lines
3294        let file_path = temp_path.join("multihunk.txt");
3295        let mut file = File::create(&file_path).unwrap();
3296        for i in 1..=20 {
3297            writeln!(file, "Line {}", i).unwrap();
3298        }
3299        drop(file);
3300
3301        // Stage and commit initial
3302        stage_file(&temp_path, "multihunk.txt").unwrap();
3303        commit_changes(&temp_path, "initial commit").unwrap();
3304
3305        // Now modify lines 2 and 18 to create two distinct hunks
3306        let mut file = File::create(&file_path).unwrap();
3307        for i in 1..=20 {
3308            if i == 2 || i == 18 {
3309                writeln!(file, "Line {} modified", i).unwrap();
3310            } else {
3311                writeln!(file, "Line {}", i).unwrap();
3312            }
3313        }
3314        drop(file);
3315
3316        // Get the unstaged diff lines
3317        let diff_lines = get_worktree_file_diff(&temp_path, "multihunk.txt", false);
3318        // Identify hunk ranges. A hunk header starts with "@@"
3319        let mut hunk_ranges = Vec::new();
3320        let mut current_start = None;
3321        for (i, line) in diff_lines.iter().enumerate() {
3322            if line.kind == DiffLineKind::Header {
3323                if let Some(start) = current_start {
3324                    hunk_ranges.push(start..i);
3325                }
3326                current_start = Some(i);
3327            }
3328        }
3329        if let Some(start) = current_start {
3330            hunk_ranges.push(start..diff_lines.len());
3331        }
3332
3333        // We expect exactly 2 hunks
3334        assert_eq!(hunk_ranges.len(), 2);
3335
3336        // Stage the second hunk
3337        let hunk2 = &diff_lines[hunk_ranges[1].clone()];
3338        stage_hunk(&temp_path, "multihunk.txt", hunk2).unwrap();
3339
3340        // Now check staged diff for the file: it should contain the second modification
3341        let staged_diff = get_worktree_file_diff(&temp_path, "multihunk.txt", true);
3342        let staged_content: String =
3343            staged_diff.iter().map(|l| l.content.as_str()).collect::<Vec<_>>().join("\n");
3344        assert!(staged_content.contains("Line 18 modified"));
3345        assert!(!staged_content.contains("Line 2 modified"));
3346
3347        // Check unstaged diff for the file: it should contain the first modification
3348        let unstaged_diff = get_worktree_file_diff(&temp_path, "multihunk.txt", false);
3349        let unstaged_content: String =
3350            unstaged_diff.iter().map(|l| l.content.as_str()).collect::<Vec<_>>().join("\n");
3351        assert!(unstaged_content.contains("Line 2 modified"));
3352        assert!(!unstaged_content.contains("Line 18 modified"));
3353
3354        // Unstage the staged hunk
3355        let staged_hunk_ranges = {
3356            let mut ranges = Vec::new();
3357            let mut current_start = None;
3358            for (i, line) in staged_diff.iter().enumerate() {
3359                if line.kind == DiffLineKind::Header {
3360                    if let Some(start) = current_start {
3361                        ranges.push(start..i);
3362                    }
3363                    current_start = Some(i);
3364                }
3365            }
3366            if let Some(start) = current_start {
3367                ranges.push(start..staged_diff.len());
3368            }
3369            ranges
3370        };
3371        assert_eq!(staged_hunk_ranges.len(), 1);
3372        let staged_hunk = &staged_diff[staged_hunk_ranges[0].clone()];
3373        unstage_hunk(&temp_path, "multihunk.txt", staged_hunk).unwrap();
3374
3375        // Staged diff should now be empty
3376        let staged_diff_after = get_worktree_file_diff(&temp_path, "multihunk.txt", true);
3377        assert!(staged_diff_after.is_empty());
3378
3379        // Clean up
3380        let _ = std::fs::remove_dir_all(&temp_path);
3381    }
3382
3383    #[test]
3384    #[cfg_attr(target_os = "windows", ignore = "CRLF patch issues on Windows")]
3385    fn test_discard_hunk() {
3386        let mut temp_path = std::env::temp_dir();
3387        temp_path.push(format!(
3388            "twig_test_{}",
3389            std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3390        ));
3391        std::fs::create_dir_all(&temp_path).unwrap();
3392
3393        // Init repo
3394        let repo = Repository::init(&temp_path).unwrap();
3395
3396        // Configure author
3397        let mut config = repo.config().unwrap();
3398        config.set_str("user.name", "Test User").unwrap();
3399        config.set_str("user.email", "test@example.com").unwrap();
3400
3401        // Create initial file with multiple lines
3402        let file_path = temp_path.join("discardhunk.txt");
3403        let mut file = File::create(&file_path).unwrap();
3404        for i in 1..=20 {
3405            writeln!(file, "Line {}", i).unwrap();
3406        }
3407        drop(file);
3408
3409        // Stage and commit initial
3410        stage_file(&temp_path, "discardhunk.txt").unwrap();
3411        commit_changes(&temp_path, "initial commit").unwrap();
3412
3413        // Now modify lines 2 and 18 to create two distinct hunks
3414        let mut file = File::create(&file_path).unwrap();
3415        for i in 1..=20 {
3416            if i == 2 || i == 18 {
3417                writeln!(file, "Line {} modified", i).unwrap();
3418            } else {
3419                writeln!(file, "Line {}", i).unwrap();
3420            }
3421        }
3422        drop(file);
3423
3424        // Get the unstaged diff lines
3425        let diff_lines = get_worktree_file_diff(&temp_path, "discardhunk.txt", false);
3426        // Identify hunk ranges
3427        let mut hunk_ranges = Vec::new();
3428        let mut current_start = None;
3429        for (i, line) in diff_lines.iter().enumerate() {
3430            if line.kind == DiffLineKind::Header {
3431                if let Some(start) = current_start {
3432                    hunk_ranges.push(start..i);
3433                }
3434                current_start = Some(i);
3435            }
3436        }
3437        if let Some(start) = current_start {
3438            hunk_ranges.push(start..diff_lines.len());
3439        }
3440
3441        // We expect exactly 2 hunks
3442        assert_eq!(hunk_ranges.len(), 2);
3443
3444        // Discard the second hunk (Line 18 modified)
3445        let hunk2 = &diff_lines[hunk_ranges[1].clone()];
3446        discard_hunk(&temp_path, "discardhunk.txt", hunk2).unwrap();
3447
3448        // Now check file contents: line 18 should be reverted to "Line 18", while line 2 should remain "Line 2 modified"
3449        let contents = std::fs::read_to_string(&file_path).unwrap();
3450        assert!(contents.contains("Line 2 modified"));
3451        assert!(contents.contains("Line 18\n"));
3452        assert!(!contents.contains("Line 18 modified"));
3453
3454        // Clean up
3455        let _ = std::fs::remove_dir_all(&temp_path);
3456    }
3457
3458    #[test]
3459    #[cfg_attr(target_os = "windows", ignore = "CRLF patch issues on Windows")]
3460    fn test_stage_unstage_discard_line() {
3461        let mut temp_path = std::env::temp_dir();
3462        temp_path.push(format!(
3463            "twig_test_{}",
3464            std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3465        ));
3466        std::fs::create_dir_all(&temp_path).unwrap();
3467
3468        let repo = Repository::init(&temp_path).unwrap();
3469        let mut config = repo.config().unwrap();
3470        config.set_str("user.name", "Test User").unwrap();
3471        config.set_str("user.email", "test@example.com").unwrap();
3472
3473        // 1. Create initial file
3474        let file_path = temp_path.join("line_test.txt");
3475        let mut file = File::create(&file_path).unwrap();
3476        writeln!(file, "line A").unwrap();
3477        writeln!(file, "line B").unwrap();
3478        writeln!(file, "line C").unwrap();
3479        drop(file);
3480
3481        stage_file(&temp_path, "line_test.txt").unwrap();
3482        commit_changes(&temp_path, "initial").unwrap();
3483
3484        // 2. Modify to introduce two distinct changes in one hunk
3485        let mut file = File::create(&file_path).unwrap();
3486        writeln!(file, "line A modified").unwrap();
3487        writeln!(file, "line B").unwrap();
3488        writeln!(file, "line C modified").unwrap();
3489        drop(file);
3490
3491        let diff_lines = get_worktree_file_diff(&temp_path, "line_test.txt", false);
3492        let mut hunk_ranges = Vec::new();
3493        let mut current_start = None;
3494        for (i, line) in diff_lines.iter().enumerate() {
3495            if line.kind == DiffLineKind::Header {
3496                if let Some(start) = current_start {
3497                    hunk_ranges.push(start..i);
3498                }
3499                current_start = Some(i);
3500            }
3501        }
3502        if let Some(start) = current_start {
3503            hunk_ranges.push(start..diff_lines.len());
3504        }
3505
3506        assert_eq!(hunk_ranges.len(), 1);
3507        let hunk0 = &diff_lines[hunk_ranges[0].clone()];
3508
3509        assert_eq!(hunk0[2].content, "line A modified");
3510        assert_eq!(hunk0[5].content, "line C modified");
3511
3512        // A) Stage line A modified (relative index 2)
3513        stage_line(&temp_path, "line_test.txt", hunk0, 2).unwrap();
3514
3515        // Check staged diff
3516        let staged_diff = get_worktree_file_diff(&temp_path, "line_test.txt", true);
3517        assert!(
3518            staged_diff
3519                .iter()
3520                .any(|l| l.kind == DiffLineKind::Added && l.content == "line A modified")
3521        );
3522        assert!(
3523            !staged_diff
3524                .iter()
3525                .any(|l| l.kind == DiffLineKind::Added && l.content == "line C modified")
3526        );
3527
3528        // Check unstaged diff
3529        let unstaged_diff = get_worktree_file_diff(&temp_path, "line_test.txt", false);
3530        assert!(
3531            !unstaged_diff
3532                .iter()
3533                .any(|l| l.kind == DiffLineKind::Added && l.content == "line A modified")
3534        );
3535        assert!(
3536            unstaged_diff
3537                .iter()
3538                .any(|l| l.kind == DiffLineKind::Added && l.content == "line C modified")
3539        );
3540
3541        // B) Unstage line A modified
3542        assert_eq!(staged_diff[2].content, "line A modified");
3543        unstage_line(&temp_path, "line_test.txt", &staged_diff, 2).unwrap();
3544
3545        // Staged diff should now be empty
3546        assert!(get_worktree_file_diff(&temp_path, "line_test.txt", true).is_empty());
3547
3548        // C) Discard line C modified (index 5) in unstaged diff
3549        let unstaged_diff2 = get_worktree_file_diff(&temp_path, "line_test.txt", false);
3550        assert_eq!(unstaged_diff2[5].content, "line C modified");
3551        discard_line(&temp_path, "line_test.txt", &unstaged_diff2, 5).unwrap();
3552
3553        let unstaged_diff3 = get_worktree_file_diff(&temp_path, "line_test.txt", false);
3554        let remove_idx = unstaged_diff3
3555            .iter()
3556            .position(|l| l.kind == DiffLineKind::Removed && l.content == "line C")
3557            .unwrap();
3558        discard_line(&temp_path, "line_test.txt", &unstaged_diff3, remove_idx).unwrap();
3559
3560        // File contents check
3561        let contents = std::fs::read_to_string(&file_path).unwrap();
3562        assert!(contents.contains("line A modified"));
3563        assert!(contents.contains("line B"));
3564        assert!(contents.contains("line C\n"));
3565        assert!(!contents.contains("line C modified"));
3566
3567        // Clean up
3568        let _ = std::fs::remove_dir_all(&temp_path);
3569    }
3570
3571    #[test]
3572    #[cfg_attr(target_os = "windows", ignore = "CRLF patch issues on Windows")]
3573    fn test_stage_unstage_discard_all_changes() {
3574        let mut temp_path = std::env::temp_dir();
3575        temp_path.push(format!(
3576            "twig_test_all_{}",
3577            std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3578        ));
3579        std::fs::create_dir_all(&temp_path).unwrap();
3580
3581        // Init repo
3582        let repo = Repository::init(&temp_path).unwrap();
3583
3584        // Configure author
3585        let mut config = repo.config().unwrap();
3586        config.set_str("user.name", "Test User").unwrap();
3587        config.set_str("user.email", "test@example.com").unwrap();
3588
3589        // Create initial file & commit it
3590        let file_path = temp_path.join("tracked.txt");
3591        std::fs::write(&file_path, "original content\n").unwrap();
3592        stage_file(&temp_path, "tracked.txt").unwrap();
3593        commit_changes(&temp_path, "initial").unwrap();
3594
3595        // 1. Make a modification and create a new untracked file
3596        std::fs::write(&file_path, "modified content\n").unwrap();
3597        let untracked_path = temp_path.join("untracked.txt");
3598        std::fs::write(&untracked_path, "untracked content\n").unwrap();
3599
3600        // Verify status has unstaged changes
3601        let status = repo.statuses(None).unwrap();
3602        assert_eq!(status.len(), 2);
3603
3604        // Stage all changes
3605        stage_all_changes(&temp_path).unwrap();
3606
3607        // Verify all changes are staged
3608        let status = repo.statuses(None).unwrap();
3609        for entry in status.iter() {
3610            assert!(
3611                entry.status().intersects(git2::Status::INDEX_MODIFIED | git2::Status::INDEX_NEW)
3612            );
3613        }
3614
3615        // Unstage all changes
3616        unstage_all_changes(&temp_path).unwrap();
3617
3618        // Verify all changes are unstaged again
3619        let status = repo.statuses(None).unwrap();
3620        for entry in status.iter() {
3621            assert!(entry.status().intersects(git2::Status::WT_MODIFIED | git2::Status::WT_NEW));
3622        }
3623
3624        // Discard all changes
3625        discard_all_changes(&temp_path).unwrap();
3626
3627        // Verify repo is completely clean
3628        let status = repo.statuses(None).unwrap();
3629        assert_eq!(status.len(), 0);
3630
3631        // Verify tracked file is reset and untracked file is removed
3632        let contents = std::fs::read_to_string(&file_path).unwrap();
3633        assert_eq!(contents, "original content\n");
3634        assert!(!untracked_path.exists());
3635
3636        // Clean up
3637        let _ = std::fs::remove_dir_all(&temp_path);
3638    }
3639
3640    #[test]
3641    fn test_merge_conflicts_flow() {
3642        let mut temp_path = std::env::temp_dir();
3643        temp_path.push(format!(
3644            "twig_test_conflict_{}",
3645            std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3646        ));
3647        std::fs::create_dir_all(&temp_path).unwrap();
3648
3649        // Init repo
3650        let repo = Repository::init(&temp_path).unwrap();
3651
3652        // Configure author
3653        let mut config = repo.config().unwrap();
3654        config.set_str("user.name", "Test User").unwrap();
3655        config.set_str("user.email", "test@example.com").unwrap();
3656
3657        // 1. Initial commit on main
3658        let file_path = temp_path.join("conflict.txt");
3659        std::fs::write(&file_path, "line 1\nline 2\nline 3\n").unwrap();
3660        stage_file(&temp_path, "conflict.txt").unwrap();
3661        commit_changes(&temp_path, "initial commit").unwrap();
3662
3663        // Get the main branch name first
3664        let output = std::process::Command::new("git")
3665            .env("GIT_TERMINAL_PROMPT", "0")
3666            .env("GIT_SSH_COMMAND", ssh_command_val())
3667            .args(["symbolic-ref", "--short", "HEAD"])
3668            .current_dir(&temp_path)
3669            .output()
3670            .unwrap();
3671        let main_branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
3672
3673        // 2. Create feature branch and edit
3674        std::process::Command::new("git")
3675            .env("GIT_TERMINAL_PROMPT", "0")
3676            .env("GIT_SSH_COMMAND", ssh_command_val())
3677            .args(["checkout", "-b", "feature"])
3678            .current_dir(&temp_path)
3679            .output()
3680            .unwrap();
3681
3682        std::fs::write(&file_path, "line 1\nline 2 on feature\nline 3\n").unwrap();
3683        stage_file(&temp_path, "conflict.txt").unwrap();
3684        commit_changes(&temp_path, "feature commit").unwrap();
3685
3686        // 3. Checkout main/master and edit differently
3687        std::process::Command::new("git")
3688            .env("GIT_TERMINAL_PROMPT", "0")
3689            .env("GIT_SSH_COMMAND", ssh_command_val())
3690            .args(["checkout", &main_branch])
3691            .current_dir(&temp_path)
3692            .output()
3693            .unwrap();
3694
3695        std::fs::write(&file_path, "line 1\nline 2 on main\nline 3\n").unwrap();
3696        stage_file(&temp_path, "conflict.txt").unwrap();
3697        commit_changes(&temp_path, "main commit").unwrap();
3698
3699        // 4. Merge feature into main -> conflict
3700        assert!(!is_merging(&temp_path));
3701        let merge_output = std::process::Command::new("git")
3702            .env("GIT_TERMINAL_PROMPT", "0")
3703            .env("GIT_SSH_COMMAND", ssh_command_val())
3704            .args(["merge", "feature"])
3705            .current_dir(&temp_path)
3706            .output()
3707            .unwrap();
3708
3709        assert!(!merge_output.status.success());
3710        assert!(is_merging(&temp_path));
3711
3712        // 5. Check conflict markers diff
3713        let diff = get_conflict_markers_diff(&temp_path, "conflict.txt");
3714        assert!(!diff.is_empty());
3715        let has_separator = diff.iter().any(|l| matches!(l.kind, DiffLineKind::ConflictSeparator));
3716        let has_ours = diff.iter().any(|l| matches!(l.kind, DiffLineKind::ConflictOurs));
3717        let has_theirs = diff.iter().any(|l| matches!(l.kind, DiffLineKind::ConflictTheirs));
3718        assert!(has_separator);
3719        assert!(has_ours);
3720        assert!(has_theirs);
3721
3722        // 6. Abort merge and verify
3723        abort_merge(&temp_path).unwrap();
3724        assert!(!is_merging(&temp_path));
3725
3726        // 7. Conflict again to test resolve_ours/resolve_theirs
3727        std::process::Command::new("git")
3728            .env("GIT_TERMINAL_PROMPT", "0")
3729            .env("GIT_SSH_COMMAND", ssh_command_val())
3730            .args(["merge", "feature"])
3731            .current_dir(&temp_path)
3732            .output()
3733            .unwrap();
3734        assert!(is_merging(&temp_path));
3735
3736        // Test resolve_ours
3737        resolve_ours(&temp_path, "conflict.txt").unwrap();
3738        let contents = std::fs::read_to_string(&file_path).unwrap();
3739        assert!(contents.contains("line 2 on main"));
3740        assert!(!contents.contains("<<<<<<<"));
3741
3742        // Since it's resolved, we can continue merge
3743        continue_merge(&temp_path).unwrap();
3744        assert!(!is_merging(&temp_path));
3745
3746        // 8. Test resolve_theirs by resetting main to before the merge
3747        std::process::Command::new("git")
3748            .env("GIT_TERMINAL_PROMPT", "0")
3749            .env("GIT_SSH_COMMAND", ssh_command_val())
3750            .args(["reset", "--hard", "HEAD~1"])
3751            .current_dir(&temp_path)
3752            .output()
3753            .unwrap();
3754
3755        std::process::Command::new("git")
3756            .env("GIT_TERMINAL_PROMPT", "0")
3757            .env("GIT_SSH_COMMAND", ssh_command_val())
3758            .args(["merge", "feature"])
3759            .current_dir(&temp_path)
3760            .output()
3761            .unwrap();
3762        assert!(is_merging(&temp_path));
3763
3764        resolve_theirs(&temp_path, "conflict.txt").unwrap();
3765        let contents_theirs = std::fs::read_to_string(&file_path).unwrap();
3766        assert!(contents_theirs.contains("line 2 on feature"));
3767        assert!(!contents_theirs.contains("<<<<<<<"));
3768
3769        continue_merge(&temp_path).unwrap();
3770        assert!(!is_merging(&temp_path));
3771
3772        // Clean up
3773        let _ = std::fs::remove_dir_all(&temp_path);
3774    }
3775
3776    #[test]
3777    fn test_resolve_conflict_hunk() {
3778        let mut temp_path = std::env::temp_dir();
3779        temp_path.push(format!(
3780            "twig_test_hunk_conflict_{}",
3781            std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3782        ));
3783        std::fs::create_dir_all(&temp_path).unwrap();
3784
3785        // Init repo
3786        let repo = Repository::init(&temp_path).unwrap();
3787
3788        // Configure author
3789        let mut config = repo.config().unwrap();
3790        config.set_str("user.name", "Test User").unwrap();
3791        config.set_str("user.email", "test@example.com").unwrap();
3792
3793        // 1. Initial commit on main
3794        let file_path = temp_path.join("conflict.txt");
3795        let initial_lines = "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\n";
3796        std::fs::write(&file_path, initial_lines).unwrap();
3797        stage_file(&temp_path, "conflict.txt").unwrap();
3798        commit_changes(&temp_path, "initial commit").unwrap();
3799
3800        // Get the main branch name first
3801        let output = std::process::Command::new("git")
3802            .env("GIT_TERMINAL_PROMPT", "0")
3803            .env("GIT_SSH_COMMAND", ssh_command_val())
3804            .args(["symbolic-ref", "--short", "HEAD"])
3805            .current_dir(&temp_path)
3806            .output()
3807            .unwrap();
3808        let main_branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
3809
3810        // 2. Create feature branch and edit line 2 and line 11
3811        std::process::Command::new("git")
3812            .env("GIT_TERMINAL_PROMPT", "0")
3813            .env("GIT_SSH_COMMAND", ssh_command_val())
3814            .args(["checkout", "-b", "feature"])
3815            .current_dir(&temp_path)
3816            .output()
3817            .unwrap();
3818        let feature_lines = "line 1\nline 2 on feature\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11 on feature\nline 12\n";
3819        std::fs::write(&file_path, feature_lines).unwrap();
3820        stage_file(&temp_path, "conflict.txt").unwrap();
3821        commit_changes(&temp_path, "feature commit").unwrap();
3822
3823        // 3. Checkout main/master and edit line 2 and line 11 differently
3824        std::process::Command::new("git")
3825            .env("GIT_TERMINAL_PROMPT", "0")
3826            .env("GIT_SSH_COMMAND", ssh_command_val())
3827            .args(["checkout", &main_branch])
3828            .current_dir(&temp_path)
3829            .output()
3830            .unwrap();
3831        let main_lines = "line 1\nline 2 on main\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11 on main\nline 12\n";
3832        std::fs::write(&file_path, main_lines).unwrap();
3833        stage_file(&temp_path, "conflict.txt").unwrap();
3834        commit_changes(&temp_path, "main commit").unwrap();
3835
3836        // 4. Merge feature into main -> conflict
3837        let merge_output = std::process::Command::new("git")
3838            .env("GIT_TERMINAL_PROMPT", "0")
3839            .env("GIT_SSH_COMMAND", ssh_command_val())
3840            .args(["merge", "feature"])
3841            .current_dir(&temp_path)
3842            .output()
3843            .unwrap();
3844        assert!(!merge_output.status.success());
3845        assert!(is_merging(&temp_path));
3846
3847        // 5. Resolve first hunk as Ours
3848        resolve_conflict_hunk(&temp_path, "conflict.txt", 0, true).unwrap();
3849        let contents_after_first = std::fs::read_to_string(&file_path).unwrap();
3850        // Line 2 should be resolved to main
3851        assert!(contents_after_first.contains("line 2 on main"));
3852        assert!(!contents_after_first.contains("line 2 on feature"));
3853        // Line 11 should still have conflict markers
3854        assert!(contents_after_first.contains("<<<<<<<"));
3855        assert!(contents_after_first.contains("line 11 on main"));
3856        assert!(contents_after_first.contains("line 11 on feature"));
3857
3858        // Repo should still be in a merging state because 1 conflict hunk remains
3859        assert!(is_merging(&temp_path));
3860
3861        // 6. Resolve second hunk (which is now hunk 0, since hunk 0 was resolved and removed)
3862        // Wait, did the hunk count change? Yes, the first conflict block was removed,
3863        // so the remaining conflict block at line 11 becomes the 0th hunk in the file!
3864        // Let's call resolve_conflict_hunk with hunk_idx 0!
3865        resolve_conflict_hunk(&temp_path, "conflict.txt", 0, false).unwrap();
3866        let contents_after_second = std::fs::read_to_string(&file_path).unwrap();
3867        // Both lines should be resolved, no conflict markers left
3868        assert!(contents_after_second.contains("line 2 on main"));
3869        assert!(contents_after_second.contains("line 11 on feature"));
3870        assert!(!contents_after_second.contains("<<<<<<<"));
3871
3872        // File is fully resolved so it should have been automatically staged
3873        let status = repo.statuses(None).unwrap();
3874        assert_eq!(status.len(), 1);
3875        assert!(status.get(0).unwrap().status().contains(git2::Status::INDEX_MODIFIED));
3876
3877        // Continue and finalize the merge
3878        continue_merge(&temp_path).unwrap();
3879        assert!(!is_merging(&temp_path));
3880
3881        // Clean up
3882        let _ = std::fs::remove_dir_all(&temp_path);
3883    }
3884
3885    #[test]
3886    fn test_branch_and_commit_helpers() {
3887        let mut temp_path = std::env::temp_dir();
3888        temp_path.push(format!(
3889            "twig_test_helpers_{}",
3890            std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
3891        ));
3892        let _ = std::fs::remove_dir_all(&temp_path);
3893        std::fs::create_dir_all(&temp_path).unwrap();
3894
3895        // 1. Initialize repository
3896        let repo = Repository::init(&temp_path).unwrap();
3897
3898        // Configure author
3899        let mut config = repo.config().unwrap();
3900        config.set_str("user.name", "Test User").unwrap();
3901        config.set_str("user.email", "test@example.com").unwrap();
3902
3903        // 2. Create initial commit (root commit)
3904        let file_path = temp_path.join("test.txt");
3905        std::fs::write(&file_path, "root content").unwrap();
3906        stage_file(&temp_path, "test.txt").unwrap();
3907        commit_changes(&temp_path, "root commit").unwrap();
3908
3909        let head_oid = repo.head().unwrap().target().unwrap().to_string();
3910        assert!(is_root_commit(&temp_path, &head_oid));
3911
3912        // 3. Create second commit (non-root commit)
3913        std::fs::write(&file_path, "second content").unwrap();
3914        stage_file(&temp_path, "test.txt").unwrap();
3915        commit_changes(&temp_path, "second commit").unwrap();
3916
3917        let new_head_oid = repo.head().unwrap().target().unwrap().to_string();
3918        assert!(!is_root_commit(&temp_path, &new_head_oid));
3919
3920        // 4. Test push target with first remote config
3921        // Currently there are no remotes, so it should return None or fallback.
3922        // Wait, get_branch_push_target returns None if no remotes are found and no upstream config.
3923        // Let's add a remote.
3924        remote_add(&temp_path, "origin", "https://github.com/example/repo.git").unwrap();
3925        let target = get_branch_push_target(&temp_path, "master")
3926            .or_else(|| get_branch_push_target(&temp_path, "main"));
3927        assert!(target.is_some());
3928        let (remote_name, set_upstream) = target.unwrap();
3929        assert_eq!(remote_name, "origin");
3930        assert!(set_upstream);
3931
3932        // Clean up
3933        let _ = std::fs::remove_dir_all(&temp_path);
3934    }
3935}