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