Skip to main content

gitwig_core/
lib.rs

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