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