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