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