Skip to main content

torii_lib/vcs/
core_extensions.rs

1// Extended Git operations for Torii
2use git2::BranchType;
3use crate::error::Result;
4use crate::core::GitRepo;
5use chrono::NaiveDateTime;
6
7impl GitRepo {
8    /// Show commit history
9    pub fn log(
10        &self,
11        count: Option<usize>,
12        oneline: bool,
13        graph: bool,
14        author: Option<&str>,
15        since: Option<&str>,
16        until: Option<&str>,
17        grep: Option<&str>,
18        stat: bool,
19        signatures: bool,
20    ) -> Result<()> {
21        if graph {
22            return self.log_graph(count.unwrap_or(50), true);
23        }
24        let mut revwalk = self.repository().revwalk()?;
25        revwalk.push_head()?;
26
27        let max_count = count.unwrap_or(10);
28        let mut shown = 0;
29
30        // Parse date filters
31        let since_ts: Option<i64> = since.and_then(|s| {
32            NaiveDateTime::parse_from_str(&format!("{} 00:00:00", s), "%Y-%m-%d %H:%M:%S")
33                .ok()
34                .map(|dt| dt.and_utc().timestamp())
35        });
36        let until_ts: Option<i64> = until.and_then(|s| {
37            NaiveDateTime::parse_from_str(&format!("{} 23:59:59", s), "%Y-%m-%d %H:%M:%S")
38                .ok()
39                .map(|dt| dt.and_utc().timestamp())
40        });
41
42        println!("πŸ“œ Commit History:");
43        println!();
44
45        for oid in revwalk {
46            if shown >= max_count {
47                break;
48            }
49
50            let oid = oid?;
51            let commit = self.repository().find_commit(oid)?;
52            let ts = commit.time().seconds();
53
54            // Author filter
55            if let Some(filter) = author {
56                let name = commit.author().name().unwrap_or("").to_lowercase();
57                let email = commit.author().email().unwrap_or("").to_lowercase();
58                let f = filter.to_lowercase();
59                if !name.contains(&f) && !email.contains(&f) {
60                    continue;
61                }
62            }
63
64            // Date filters
65            if let Some(s) = since_ts {
66                if ts < s { continue; }
67            }
68            if let Some(u) = until_ts {
69                if ts > u { continue; }
70            }
71
72            // Grep filter
73            if let Some(pattern) = grep {
74                let msg = commit.message().unwrap_or("");
75                if !msg.to_lowercase().contains(&pattern.to_lowercase()) {
76                    continue;
77                }
78            }
79
80            // 0.7.35: compute a single-letter signature status when
81            // the caller asked for it (G=good, U=unknown, B=bad,
82            // N=none). Pure presentation β€” we still iterate every
83            // commit but only spawn `gpg --verify` when there's an
84            // armor attached, so unsigned histories stay cheap.
85            let sig_letter: Option<&'static str> = if signatures {
86                Some(signature_letter(self.repository(), oid))
87            } else {
88                None
89            };
90
91            if oneline {
92                let short_id = &oid.to_string()[..7];
93                let message = commit.message().unwrap_or("<no message>").lines().next().unwrap_or("");
94                if let Some(l) = sig_letter {
95                    println!("  {} {} {}", l, short_id, message);
96                } else {
97                    println!("  {} {}", short_id, message);
98                }
99            } else {
100                if let Some(l) = sig_letter {
101                    println!("  commit {} [{}]", oid, l);
102                } else {
103                    println!("  commit {}", oid);
104                }
105                if let Some(author_name) = commit.author().name() {
106                    println!("  Author: {}", author_name);
107                }
108                println!("  Date:   {}", chrono::DateTime::from_timestamp(ts, 0)
109                    .map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
110                    .unwrap_or_else(|| "<unknown>".to_string()));
111                println!();
112                if let Some(msg) = commit.message() {
113                    for line in msg.lines() {
114                        println!("      {}", line);
115                    }
116                }
117                println!();
118
119                // Stat: show changed files count
120                if stat {
121                    if let Ok(parent) = commit.parent(0) {
122                        let old_tree = parent.tree().ok();
123                        let new_tree = commit.tree().ok();
124                        if let (Some(old), Some(new)) = (old_tree, new_tree) {
125                            let diff = self.repository().diff_tree_to_tree(Some(&old), Some(&new), None);
126                            if let Ok(diff) = diff {
127                                let stats = diff.stats()?;
128                                println!("  {} files changed, {} insertions(+), {} deletions(-)",
129                                    stats.files_changed(),
130                                    stats.insertions(),
131                                    stats.deletions()
132                                );
133                                println!();
134                            }
135                        }
136                    }
137                }
138            }
139
140            shown += 1;
141        }
142
143        Ok(())
144    }
145
146    /// Render commit history as a graph (`torii log --graph`).
147    /// Style picked from TORII_GRAPH_STYLE env var (ascii|curves|heavy);
148    /// defaults to curves.
149    fn log_graph(&self, limit: usize, include_all: bool) -> Result<()> {
150        let style = std::env::var("TORII_GRAPH_STYLE")
151            .ok()
152            .map(|s| crate::graph::GraphStyle::from_str(&s))
153            .unwrap_or_default();
154        let rendered = crate::graph::render_repo_with(self.repository(), limit, include_all, style)
155            .map_err(|e| crate::error::ToriiError::Git(e))?;
156        let extra_pad = style.expanded_extra_lines();
157        println!("πŸ“œ Commit Graph:");
158        println!();
159        for (commit, row) in &rendered {
160            if !row.transition_line.is_empty() {
161                println!("  {}", row.transition_line);
162            }
163            let refs_str = if commit.refs.is_empty() {
164                String::new()
165            } else {
166                let badges: Vec<String> = commit
167                    .refs
168                    .iter()
169                    .map(|r| crate::graph::format_ref_badge(r))
170                    .collect();
171                format!("{} ", badges.join(" "))
172            };
173            let summary = if commit.summary.chars().count() > 80 {
174                let cut: String = commit.summary.chars().take(79).collect();
175                format!("{}…", cut)
176            } else {
177                commit.summary.clone()
178            };
179
180            if extra_pad > 0 {
181                // Expanded: commit row carries node + hash + badges; second
182                // row indents the message under the lanes.
183                println!(
184                    "  {} {} {}",
185                    row.commit_line, commit.short_id, refs_str.trim_end()
186                );
187                let pad_row = crate::graph::padding_row(&row.commit_line, style);
188                println!("  {} {}", pad_row, summary);
189                for _ in 1..extra_pad {
190                    println!("  {}", pad_row);
191                }
192            } else {
193                println!(
194                    "  {} {} {}{}",
195                    row.commit_line, commit.short_id, refs_str, summary
196                );
197            }
198        }
199        Ok(())
200    }
201
202    /// Show reflog (HEAD movement history)
203    pub fn show_reflog(&self, count: usize) -> Result<()> {
204        let reflog = self.repo.reflog("HEAD")
205            .map_err(|e| crate::error::ToriiError::Git(e))?;
206
207        println!("πŸ“‹ Reflog (HEAD movements):");
208        println!();
209
210        for (i, entry) in reflog.iter().enumerate() {
211            if i >= count {
212                break;
213            }
214            let oid_short = entry.id_new().to_string();
215            let oid_short = &oid_short[..7.min(oid_short.len())];
216            let message = entry.message().unwrap_or("");
217            println!("  {} {}", oid_short, message);
218        }
219
220        println!();
221        println!("πŸ’‘ Restore a state: torii save --reset <commit-hash> --reset-mode soft");
222
223        Ok(())
224    }
225
226    /// Rebase with a pre-written todo file (no editor required).
227    /// Unix-only because it shells out to `git rebase -i` and needs a
228    /// real shell to invoke the GIT_SEQUENCE_EDITOR helper script.
229    #[cfg(unix)]
230    pub fn rebase_with_todo(&self, base: &str, todo_file: &std::path::Path) -> Result<()> {
231        // .git/ always has a parent (the work tree) for non-bare repos.
232        // Surface a clear error rather than panicking if libgit2 ever surprises us.
233        let repo_path = self.repo.path().parent()
234            .ok_or_else(|| crate::error::ToriiError::RepoState("git directory has no parent (bare repo?)".to_string()))?
235            .to_path_buf();
236        let todo_abs = todo_file.canonicalize().map_err(|_| {
237            crate::error::ToriiError::Usage(
238                format!("Todo file not found: {}", todo_file.display())
239            )
240        })?;
241        println!("πŸ”„ Rebasing from {} using todo file: {}", base, todo_abs.display());
242        let (todo_for_git, reword_map) = preprocess_reword_todo(&todo_abs)?;
243        let editor = format!("cp {}", todo_for_git.display());
244        let mut cmd = std::process::Command::new("git");
245        cmd.args(["rebase", "-i", base])
246            .env("GIT_SEQUENCE_EDITOR", &editor)
247            .current_dir(&repo_path);
248        install_message_editor(&mut cmd, &reword_map, &repo_path)?;
249        let status = cmd.status()?;
250        report_rebase_outcome(&repo_path, status);
251        Ok(())
252    }
253
254    #[cfg(not(unix))]
255    pub fn rebase_with_todo(&self, _base: &str, _todo_file: &std::path::Path) -> Result<()> {
256        Err(crate::error::ToriiError::RepoState("Interactive rebase with todo file requires a Unix shell. Not supported on this platform.".to_string()))
257    }
258
259    /// Interactive rebase
260    #[cfg(unix)]
261    pub fn rebase_interactive(&self, base: &str) -> Result<()> {
262        // .git/ always has a parent (the work tree) for non-bare repos.
263        // Surface a clear error rather than panicking if libgit2 ever surprises us.
264        let repo_path = self.repo.path().parent()
265            .ok_or_else(|| crate::error::ToriiError::RepoState("git directory has no parent (bare repo?)".to_string()))?
266            .to_path_buf();
267        println!("πŸ”„ Starting interactive rebase onto {}...", base);
268        let status = std::process::Command::new("git")
269            .args(["rebase", "-i", base])
270            .current_dir(&repo_path)
271            .status()?;
272        report_rebase_outcome(&repo_path, status);
273        Ok(())
274    }
275
276    #[cfg(not(unix))]
277    pub fn rebase_interactive(&self, _base: &str) -> Result<()> {
278        Err(crate::error::ToriiError::RepoState("Interactive rebase requires a Unix terminal. Not supported on this platform.".to_string()))
279    }
280
281    /// Interactive rebase from the root commit (`git rebase -i --root`)
282    #[cfg(unix)]
283    pub fn rebase_root_interactive(&self) -> Result<()> {
284        // .git/ always has a parent (the work tree) for non-bare repos.
285        // Surface a clear error rather than panicking if libgit2 ever surprises us.
286        let repo_path = self.repo.path().parent()
287            .ok_or_else(|| crate::error::ToriiError::RepoState("git directory has no parent (bare repo?)".to_string()))?
288            .to_path_buf();
289        println!("πŸ”„ Starting interactive rebase from root...");
290        let status = std::process::Command::new("git")
291            .args(["rebase", "-i", "--root"])
292            .current_dir(&repo_path)
293            .status()?;
294        report_rebase_outcome(&repo_path, status);
295        Ok(())
296    }
297
298    #[cfg(not(unix))]
299    pub fn rebase_root_interactive(&self) -> Result<()> {
300        Err(crate::error::ToriiError::RepoState("Interactive rebase requires a Unix terminal. Not supported on this platform.".to_string()))
301    }
302
303    /// Rebase from the root commit using a pre-written todo file
304    #[cfg(unix)]
305    pub fn rebase_root_with_todo(&self, todo_file: &std::path::Path) -> Result<()> {
306        // .git/ always has a parent (the work tree) for non-bare repos.
307        // Surface a clear error rather than panicking if libgit2 ever surprises us.
308        let repo_path = self.repo.path().parent()
309            .ok_or_else(|| crate::error::ToriiError::RepoState("git directory has no parent (bare repo?)".to_string()))?
310            .to_path_buf();
311        let todo_abs = todo_file.canonicalize().map_err(|_| {
312            crate::error::ToriiError::Usage(
313                format!("Todo file not found: {}", todo_file.display())
314            )
315        })?;
316        println!("πŸ”„ Rebasing from root using todo file: {}", todo_abs.display());
317        let (todo_for_git, reword_map) = preprocess_reword_todo(&todo_abs)?;
318        let editor = format!("cp {}", todo_for_git.display());
319        let mut cmd = std::process::Command::new("git");
320        cmd.args(["rebase", "-i", "--root"])
321            .env("GIT_SEQUENCE_EDITOR", &editor)
322            .current_dir(&repo_path);
323        install_message_editor(&mut cmd, &reword_map, &repo_path)?;
324        let status = cmd.status()?;
325        report_rebase_outcome(&repo_path, status);
326        Ok(())
327    }
328
329    #[cfg(not(unix))]
330    pub fn rebase_root_with_todo(&self, _todo_file: &std::path::Path) -> Result<()> {
331        Err(crate::error::ToriiError::RepoState("Interactive rebase with todo file requires a Unix shell. Not supported on this platform.".to_string()))
332    }
333
334    /// Continue an in-progress rebase
335    pub fn rebase_continue(&self) -> Result<()> {
336        // Two rebase formats coexist:
337        //   - libgit2-initiated rebases β†’ use Repository::open_rebase
338        //   - `git rebase -i` (CLI, used for interactive/edit/reword) β†’ not
339        //     openable via libgit2; delegate to `git rebase --continue`
340        if self.has_cli_rebase_in_progress() {
341            return self.delegate_rebase_subcommand("--continue", "continued");
342        }
343        let mut rebase = self.repo.open_rebase(None)
344            .map_err(|_| crate::error::ToriiError::RepoState("No rebase in progress".to_string()))?;
345
346        let sig = crate::core::resolve_signature(&self.repo)?;
347
348        // Commit the currently resolved step
349        rebase.commit(None, &sig, None)
350            .map_err(|e| crate::error::ToriiError::Git(e))?;
351
352        // Apply remaining steps
353        while let Some(op) = rebase.next() {
354            let _op = op.map_err(|e| crate::error::ToriiError::Git(e))?;
355            rebase.commit(None, &sig, None)
356                .map_err(|e| crate::error::ToriiError::Git(e))?;
357        }
358
359        rebase.finish(Some(&sig))
360            .map_err(|e| crate::error::ToriiError::Git(e))?;
361
362        println!("βœ… Rebase continued");
363        Ok(())
364    }
365
366    /// Abort the current rebase
367    pub fn rebase_abort(&self) -> Result<()> {
368        if self.has_cli_rebase_in_progress() {
369            return self.delegate_rebase_subcommand("--abort", "aborted");
370        }
371        let mut rebase = self.repo.open_rebase(None)
372            .map_err(|_| crate::error::ToriiError::RepoState("No rebase in progress".to_string()))?;
373
374        rebase.abort()
375            .map_err(|e| crate::error::ToriiError::Git(e))?;
376
377        println!("βœ… Rebase aborted");
378        Ok(())
379    }
380
381    /// Skip current patch in rebase
382    pub fn rebase_skip(&self) -> Result<()> {
383        if self.has_cli_rebase_in_progress() {
384            return self.delegate_rebase_subcommand("--skip", "skipped");
385        }
386        let mut rebase = self.repo.open_rebase(None)
387            .map_err(|_| crate::error::ToriiError::RepoState("No rebase in progress".to_string()))?;
388
389        let sig = crate::core::resolve_signature(&self.repo)?;
390
391        // Advance past current step without committing
392        rebase.next()
393            .ok_or_else(|| crate::error::ToriiError::RepoState("No current step to skip".to_string()))?
394            .map_err(|e| crate::error::ToriiError::Git(e))?;
395
396        // Continue remaining steps
397        while let Some(op) = rebase.next() {
398            let _op = op.map_err(|e| crate::error::ToriiError::Git(e))?;
399            rebase.commit(None, &sig, None)
400                .map_err(|e| crate::error::ToriiError::Git(e))?;
401        }
402
403        rebase.finish(Some(&sig))
404            .map_err(|e| crate::error::ToriiError::Git(e))?;
405
406        println!("βœ… Patch skipped");
407        Ok(())
408    }
409
410    /// True if a `git rebase -i` style rebase is paused on disk
411    /// (libgit2's open_rebase does not see these β€” it only handles rebases
412    /// created via the libgit2 API).
413    fn has_cli_rebase_in_progress(&self) -> bool {
414        let git_dir = self.repo.path();
415        git_dir.join("rebase-merge").exists() || git_dir.join("rebase-apply").exists()
416    }
417
418    /// Run `git rebase <flag>` for CLI-managed rebases.
419    fn delegate_rebase_subcommand(&self, flag: &str, verb: &str) -> Result<()> {
420        // .git/ always has a parent (the work tree) for non-bare repos.
421        // Surface a clear error rather than panicking if libgit2 ever surprises us.
422        let repo_path = self.repo.path().parent()
423            .ok_or_else(|| crate::error::ToriiError::RepoState("git directory has no parent (bare repo?)".to_string()))?
424            .to_path_buf();
425        let status = std::process::Command::new("git")
426            .args(["rebase", flag])
427            .current_dir(&repo_path)
428            .status()
429            .map_err(|e| crate::error::ToriiError::Subprocess { tool: "git".into(), message: format!("spawn git: {}", e) })?;
430        report_rebase_outcome(&repo_path, status);
431        // If the rebase finished cleanly (no in-progress dir), echo the verb.
432        let still_active = repo_path.join(".git").join("rebase-merge").exists()
433            || repo_path.join(".git").join("rebase-apply").exists();
434        if !still_active && status.success() {
435            println!("βœ… Rebase {}", verb);
436        }
437        Ok(())
438    }
439
440    /// Show changes
441    pub fn diff(&self, staged: bool, last: bool) -> Result<()> {
442        if last {
443            // Show diff of last commit
444            let head = self.repository().head()?.peel_to_commit()?;
445            let tree = head.tree()?;
446            
447            let parent_tree = if head.parent_count() > 0 {
448                Some(head.parent(0)?.tree()?)
449            } else {
450                None
451            };
452            
453            let diff = self.repository().diff_tree_to_tree(
454                parent_tree.as_ref(),
455                Some(&tree),
456                None,
457            )?;
458            
459            self.print_diff(&diff)?;
460        } else if staged {
461            // Show staged changes
462            let head = self.repository().head()?.peel_to_tree()?;
463            let diff = self.repository().diff_tree_to_index(Some(&head), None, None)?;
464            self.print_diff(&diff)?;
465        } else {
466            // Show unstaged changes
467            let diff = self.repository().diff_index_to_workdir(None, None)?;
468            self.print_diff(&diff)?;
469        }
470        
471        Ok(())
472    }
473
474    fn print_diff(&self, diff: &git2::Diff) -> Result<()> {
475        diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
476            let origin = line.origin();
477            let content = std::str::from_utf8(line.content()).unwrap_or("<binary>");
478            
479            match origin {
480                '+' => print!("\x1b[32m+{}\x1b[0m", content),
481                '-' => print!("\x1b[31m-{}\x1b[0m", content),
482                _ => print!(" {}", content),
483            }
484            true
485        })?;
486        
487        Ok(())
488    }
489
490    /// List local branches
491    pub fn list_branches(&self) -> Result<Vec<String>> {
492        let branches = self.repository().branches(Some(BranchType::Local))?;
493        let mut branch_names = Vec::new();
494
495        for branch in branches {
496            let (branch, _) = branch?;
497            if let Some(name) = branch.name()? {
498                branch_names.push(name.to_string());
499            }
500        }
501
502        Ok(branch_names)
503    }
504
505    /// List remote branches
506    pub fn list_remote_branches(&self) -> Result<Vec<String>> {
507        let branches = self.repository().branches(Some(BranchType::Remote))?;
508        let mut branch_names = Vec::new();
509
510        for branch in branches {
511            let (branch, _) = branch?;
512            if let Some(name) = branch.name()? {
513                // Skip HEAD symrefs (e.g. origin/HEAD)
514                if !name.ends_with("/HEAD") {
515                    branch_names.push(name.to_string());
516                }
517            }
518        }
519
520        Ok(branch_names)
521    }
522
523    /// Create a new branch
524    /// Create an orphan branch β€” sets HEAD to refs/heads/<name> without any parent.
525    /// The next commit will be a new root. Index and working tree are left intact
526    /// so the user can stage what they want before the first commit.
527    pub fn create_orphan_branch(&self, name: &str) -> Result<()> {
528        let refname = format!("refs/heads/{}", name);
529        // Reject if branch already exists
530        if self.repository().find_reference(&refname).is_ok() {
531            return Err(crate::error::ToriiError::Usage(
532                format!("Branch '{}' already exists", name)
533            ));
534        }
535        // Point HEAD at the (yet-unborn) ref. libgit2 allows symbolic HEAD
536        // pointing to a non-existent branch β€” the first commit will create it.
537        self.repository().set_head(&refname)
538            .map_err(|e| crate::error::ToriiError::Git(e))?;
539        Ok(())
540    }
541
542    pub fn create_branch(&self, name: &str) -> Result<()> {
543        let head = self.repository().head()?.peel_to_commit()?;
544        self.repository().branch(name, &head, false)?;
545        Ok(())
546    }
547
548    /// Delete a branch
549    pub fn delete_branch(&self, name: &str) -> Result<()> {
550        let mut branch = self.repository().find_branch(name, BranchType::Local)?;
551        branch.delete()?;
552        Ok(())
553    }
554
555    /// Switch to a branch
556    pub fn switch_branch(&self, name: &str) -> Result<()> {
557        let obj = self.repository().revparse_single(&format!("refs/heads/{}", name))?;
558        let mut builder = git2::build::CheckoutBuilder::new();
559        attach_checkout_progress(&mut builder);
560        self.repository().checkout_tree(&obj, Some(&mut builder))?;
561        self.repository().set_head(&format!("refs/heads/{}", name))?;
562        Ok(())
563    }
564
565    /// Checkout a remote branch β€” creates local tracking branch then switches to it
566    pub fn checkout_remote_branch(&self, remote_name: &str) -> Result<()> {
567        // remote_name is e.g. "origin/feature-x", local name is "feature-x"
568        let local_name = remote_name
569            .splitn(2, '/')
570            .nth(1)
571            .unwrap_or(remote_name);
572        let repo = self.repository();
573        // Create local branch tracking the remote if it doesn't exist
574        if repo.find_branch(local_name, BranchType::Local).is_err() {
575            let obj = repo.revparse_single(&format!("refs/remotes/{}", remote_name))?;
576            let commit = obj.peel_to_commit()?;
577            let mut branch = repo.branch(local_name, &commit, false)?;
578            // Set upstream tracking
579            branch.set_upstream(Some(remote_name))?;
580        }
581        self.switch_branch(local_name)
582    }
583
584    /// Clone a repository
585    pub fn clone_repo(url: &str, directory: Option<&str>) -> Result<()> {
586        let target = if let Some(dir) = directory {
587            dir.to_string()
588        } else {
589            url.split('/')
590                .last()
591                .unwrap_or("repo")
592                .trim_end_matches(".git")
593                .to_string()
594        };
595
596        // Token lookups inside the credentials closure use the unified
597        // resolver in `crate::auth::resolve_token`; no global config
598        // load needed here.
599        let mut callbacks = git2::RemoteCallbacks::new();
600        let url_owned = url.to_string();
601        callbacks.credentials(move |_url, username_from_url, allowed_types| {
602            if allowed_types.contains(git2::CredentialType::SSH_KEY) {
603                let username = username_from_url.unwrap_or("git");
604                let home = dirs::home_dir().unwrap_or_default();
605                let ed25519 = home.join(".ssh").join("id_ed25519");
606                let rsa = home.join(".ssh").join("id_rsa");
607                if ed25519.exists() {
608                    return git2::Cred::ssh_key(username, None, &ed25519, None);
609                } else if rsa.exists() {
610                    return git2::Cred::ssh_key(username, None, &rsa, None);
611                } else {
612                    return git2::Cred::ssh_key_from_agent(username);
613                }
614            }
615            if allowed_types.contains(git2::CredentialType::USER_PASS_PLAINTEXT) {
616                // Pick provider based on hostname, then route through the
617                // unified token resolver (env > local > global).
618                let provider = if url_owned.contains("github.com") {
619                    "github"
620                } else if url_owned.contains("gitlab.com") {
621                    "gitlab"
622                } else if url_owned.contains("codeberg.org") {
623                    "codeberg"
624                } else if url_owned.contains("bitbucket.org") {
625                    "bitbucket"
626                } else {
627                    "gitea"
628                };
629                if let Some(token) = crate::auth::resolve_token(provider, ".").value {
630                    return git2::Cred::userpass_plaintext("oauth2", &token);
631                }
632            }
633            git2::Cred::default()
634        });
635
636        crate::core::GitRepo::attach_fetch_progress(&mut callbacks);
637
638        let mut fetch_opts = git2::FetchOptions::new();
639        fetch_opts.remote_callbacks(callbacks);
640        // Allow shallow clones via env var (no CLI flag yet, but unblocks
641        // huge repos like servo/chromium today). 0 / unset = full history.
642        if let Ok(d) = std::env::var("TORII_CLONE_DEPTH") {
643            if let Ok(depth) = d.parse::<i32>() {
644                if depth > 0 {
645                    fetch_opts.depth(depth);
646                }
647            }
648        }
649
650        println!("πŸ”„ Cloning {url} β†’ {target}");
651        let cloned = git2::build::RepoBuilder::new()
652            .fetch_options(fetch_opts)
653            .clone(url, std::path::Path::new(&target))?;
654
655        // If the remote was empty (no advertised refs), libgit2 falls back
656        // to whatever `init.defaultBranch` is β€” historically "master" β€” even
657        // when the GitLab/GitHub project page declares "main" as the default.
658        // Override HEAD to the user-configured default (config: git.default_branch,
659        // default "main") so a follow-up `torii sync --pull` doesn't chase a
660        // ref that doesn't exist on the remote.
661        if cloned.is_empty().unwrap_or(false) {
662            let cfg = crate::config::ToriiConfig::load_global().unwrap_or_default();
663            let default = cfg.git.default_branch.trim();
664            let default = if default.is_empty() { "main" } else { default };
665            let _ = cloned.set_head(&format!("refs/heads/{}", default));
666        }
667
668        Ok(())
669    }
670
671    /// Rename a branch
672    pub fn rename_branch(&self, old_name: &str, new_name: &str) -> Result<()> {
673        let mut branch = self.repo.find_branch(old_name, git2::BranchType::Local)
674            .map_err(|e| crate::error::ToriiError::Git(e))?;
675        branch.rename(new_name, false)
676            .map_err(|e| crate::error::ToriiError::Git(e))?;
677        Ok(())
678    }
679
680    /// Rewrite commit history with new dates
681    pub fn rewrite_history(&self, start_date: &str, end_date: &str) -> Result<()> {
682        println!("πŸ”„ Rewriting commit history...");
683
684        let start_ts = NaiveDateTime::parse_from_str(&format!("{} 00:00", start_date), "%Y-%m-%d %H:%M")
685            .map_err(|e| crate::error::ToriiError::Usage(format!("Invalid start date: {}", e)))?
686            .and_utc().timestamp();
687        let end_ts = NaiveDateTime::parse_from_str(&format!("{} 23:59", end_date), "%Y-%m-%d %H:%M")
688            .map_err(|e| crate::error::ToriiError::Usage(format!("Invalid end date: {}", e)))?
689            .and_utc().timestamp();
690
691        let mut revwalk = self.repo.revwalk()
692            .map_err(|e| crate::error::ToriiError::Git(e))?;
693        revwalk.push_head().map_err(|e| crate::error::ToriiError::Git(e))?;
694        revwalk.set_sorting(git2::Sort::REVERSE | git2::Sort::TIME)
695            .map_err(|e| crate::error::ToriiError::Git(e))?;
696
697        let oids: Vec<git2::Oid> = revwalk
698            .filter_map(|r| r.ok())
699            .collect();
700
701        let total = oids.len();
702        if total == 0 { return Ok(()); }
703
704        let interval = (end_ts - start_ts) / (total as i64 - 1).max(1);
705
706        // Walk oldest→newest, rewrite each commit with new timestamp
707        let mut old_to_new: std::collections::HashMap<git2::Oid, git2::Oid> = std::collections::HashMap::new();
708
709        for (i, oid) in oids.iter().enumerate() {
710            let commit = self.repo.find_commit(*oid)
711                .map_err(|e| crate::error::ToriiError::Git(e))?;
712
713            let new_ts = start_ts + (i as i64 * interval);
714            let new_time = git2::Time::new(new_ts, 0);
715
716            let author = commit.author();
717            let committer = commit.committer();
718            let new_author = git2::Signature::new(
719                author.name().unwrap_or(""),
720                author.email().unwrap_or(""),
721                &new_time,
722            ).map_err(|e| crate::error::ToriiError::Git(e))?;
723            let new_committer = git2::Signature::new(
724                committer.name().unwrap_or(""),
725                committer.email().unwrap_or(""),
726                &new_time,
727            ).map_err(|e| crate::error::ToriiError::Git(e))?;
728
729            let tree = commit.tree().map_err(|e| crate::error::ToriiError::Git(e))?;
730            let parents: Vec<git2::Commit> = commit.parent_ids()
731                .filter_map(|pid| old_to_new.get(&pid).and_then(|new_pid| self.repo.find_commit(*new_pid).ok())
732                    .or_else(|| self.repo.find_commit(pid).ok()))
733                .collect();
734            let parent_refs: Vec<&git2::Commit> = parents.iter().collect();
735
736            let new_oid = crate::core::commit_inner_split(
737                &self.repo,
738                None,
739                &new_author,
740                &new_committer,
741                commit.message().unwrap_or(""),
742                &tree,
743                &parent_refs,
744            )?;
745
746            old_to_new.insert(*oid, new_oid);
747        }
748
749        // Update HEAD to point to the new tip
750        if let Some(new_tip) = oids.last().and_then(|oid| old_to_new.get(oid)) {
751            let head = self.repo.head().map_err(|e| crate::error::ToriiError::Git(e))?;
752            if let Some(branch_name) = head.shorthand() {
753                let refname = format!("refs/heads/{}", branch_name);
754                self.repo.reference(&refname, *new_tip, true, "history rewrite")
755                    .map_err(|e| crate::error::ToriiError::Git(e))?;
756            }
757        }
758
759        println!("βœ… Rewrote {} commits", total);
760        println!("πŸ’‘ Run 'torii sync --force' to update remote");
761        Ok(())
762    }
763
764    /// Remove a file from the entire git history
765    pub fn remove_file_from_history(&self, file_path: &str) -> Result<()> {
766        println!("πŸ—‘οΈ  Removing '{}' from entire history...", file_path);
767
768        let mut revwalk = self.repo.revwalk()
769            .map_err(|e| crate::error::ToriiError::Git(e))?;
770        revwalk.push_glob("refs/heads/*")
771            .map_err(|e| crate::error::ToriiError::Git(e))?;
772        revwalk.set_sorting(git2::Sort::REVERSE | git2::Sort::TOPOLOGICAL)
773            .map_err(|e| crate::error::ToriiError::Git(e))?;
774
775        let oids: Vec<git2::Oid> = revwalk.filter_map(|r| r.ok()).collect();
776        let mut old_to_new: std::collections::HashMap<git2::Oid, git2::Oid> = std::collections::HashMap::new();
777        let mut modified = 0usize;
778
779        for oid in &oids {
780            let commit = self.repo.find_commit(*oid)
781                .map_err(|e| crate::error::ToriiError::Git(e))?;
782            let tree = commit.tree().map_err(|e| crate::error::ToriiError::Git(e))?;
783
784            // Build new tree without the target file
785            let new_tree_oid = remove_path_from_tree(&self.repo, &tree, file_path)?;
786
787            let parents: Vec<git2::Commit> = commit.parent_ids()
788                .filter_map(|pid| {
789                    old_to_new.get(&pid)
790                        .and_then(|new_pid| self.repo.find_commit(*new_pid).ok())
791                        .or_else(|| self.repo.find_commit(pid).ok())
792                })
793                .collect();
794            let parent_refs: Vec<&git2::Commit> = parents.iter().collect();
795
796            let new_tree = self.repo.find_tree(new_tree_oid)
797                .map_err(|e| crate::error::ToriiError::Git(e))?;
798
799            if new_tree_oid != tree.id() {
800                modified += 1;
801            }
802
803            let new_oid = crate::core::commit_inner_split(
804                &self.repo,
805                None,
806                &commit.author(),
807                &commit.committer(),
808                commit.message().unwrap_or(""),
809                &new_tree,
810                &parent_refs,
811            )?;
812
813            old_to_new.insert(*oid, new_oid);
814        }
815
816        // Update all branch refs
817        let branches: Vec<(String, git2::Oid)> = self.repo.branches(Some(git2::BranchType::Local))
818            .map_err(|e| crate::error::ToriiError::Git(e))?
819            .filter_map(|b| b.ok())
820            .filter_map(|(branch, _)| {
821                let name = branch.name().ok()??.to_string();
822                let oid = branch.get().target()?;
823                Some((name, oid))
824            })
825            .collect();
826
827        for (name, old_oid) in branches {
828            if let Some(new_oid) = old_to_new.get(&old_oid) {
829                let refname = format!("refs/heads/{}", name);
830                let _ = self.repo.reference(&refname, *new_oid, true, "remove file from history");
831            }
832        }
833
834        // Sync working tree + index with the new HEAD so subsequent operations
835        // (notably `save --amend`) don't see the pre-rewrite state cached in the index.
836        if let Ok(head) = self.repo.head() {
837            if let Ok(commit) = head.peel_to_commit() {
838                let mut checkout = git2::build::CheckoutBuilder::default();
839                checkout.force();
840                let _ = self.repo.checkout_tree(commit.as_object(), Some(&mut checkout));
841                let mut index = self.repo.index().map_err(|e| crate::error::ToriiError::Git(e))?;
842                let _ = index.read_tree(&commit.tree().map_err(|e| crate::error::ToriiError::Git(e))?);
843                let _ = index.write();
844            }
845        }
846
847        println!("βœ… '{}' removed from {} commits", file_path, modified);
848        println!("πŸ’‘ Run 'torii history clean' then 'torii sync --force' to update remote");
849        Ok(())
850    }
851
852    /// Clean up repository (expire reflogs, remove stale backup refs)
853    pub fn clean_history(&self) -> Result<()> {
854        println!("🧹 Cleaning repository...");
855
856        // Remove filter-branch backup refs
857        let orig_refs = self.repo.path().join("refs").join("original");
858        if orig_refs.exists() {
859            let _ = std::fs::remove_dir_all(&orig_refs);
860        }
861
862        // Expire reflogs by deleting reflog files (git2 has no expire API)
863        let logs_dir = self.repo.path().join("logs");
864        if logs_dir.exists() {
865            let _ = remove_dir_contents(&logs_dir);
866        }
867
868        println!("βœ… Repository cleaned");
869        Ok(())
870    }
871
872    /// Verify remote repository status
873    pub fn verify_remote(&self) -> Result<()> {
874        println!("πŸ” Verifying remote status...\n");
875
876        let local_oid = self.repo.head()
877            .map_err(|e| crate::error::ToriiError::Git(e))?
878            .target()
879            .ok_or_else(|| crate::error::ToriiError::RepoState("No HEAD".to_string()))?;
880
881        let local_hash = local_oid.to_string();
882
883        // Query the live remote, not the local cached `refs/remotes/origin/*`
884        // (which is stale until the next fetch and was producing false
885        // "in sync" reports right after a silently-failed push).
886        let branch = self.get_current_branch()?;
887        let mut remote = self.repo.find_remote("origin")
888            .map_err(|e| crate::error::ToriiError::Git(e))?;
889        let remote_url = remote.url().unwrap_or("").to_string();
890        let callbacks = crate::core::GitRepo::auth_callbacks_for(&remote_url);
891        remote.connect_auth(git2::Direction::Fetch, Some(callbacks), None)
892            .map_err(|e| crate::error::ToriiError::Git(e))?;
893
894        let remote_ref = format!("refs/heads/{}", branch);
895        let remote_hash_opt = remote
896            .list()
897            .map_err(|e| crate::error::ToriiError::Git(e))?
898            .iter()
899            .find(|h| h.name() == remote_ref)
900            .map(|h| h.oid().to_string());
901
902        let _ = remote.disconnect();
903
904        let remote_hash = remote_hash_opt
905            .clone()
906            .unwrap_or_else(|| "(no such ref on remote)".to_string());
907
908        println!("Local HEAD:  {}", &local_hash[..7.min(local_hash.len())]);
909        println!("Remote HEAD: {}",
910            if remote_hash_opt.is_some() {
911                remote_hash[..7.min(remote_hash.len())].to_string()
912            } else {
913                remote_hash.clone()
914            }
915        );
916
917        match remote_hash_opt {
918            None => {
919                println!(
920                    "\n❌ Remote `origin` has no `{}` ref. Either the remote is empty \
921                     (push hasn't landed) or the branch lives under a different name.",
922                    branch
923                );
924            }
925            Some(rh) if rh == local_hash => {
926                println!("\nβœ… Local and remote are in sync");
927            }
928            Some(_) => {
929                println!("\n⚠️  Local and remote have diverged");
930                println!("πŸ’‘ Use 'torii sync --force' to push local changes");
931            }
932        }
933
934        Ok(())
935    }
936
937    /// Fetch from remote without merging
938    pub fn fetch(&self) -> Result<()> {
939        println!("πŸ”„ Fetching from remote...");
940        self.fetch_one("origin")?;
941        Ok(())
942    }
943
944    /// Fetch from a specific named remote. Errors with a helpful hint
945    /// listing configured remotes if the name is not in `.git/config`.
946    pub fn fetch_named(&self, name: &str) -> Result<()> {
947        // Validate the remote exists up-front so the error message names
948        // the missing remote (libgit2's own error is generic).
949        if self.repo.find_remote(name).is_err() {
950            let configured: Vec<String> = self.repo.remotes()
951                .map_err(|e| crate::error::ToriiError::Git(e))?
952                .iter().flatten().map(String::from).collect();
953            let list = if configured.is_empty() {
954                "(none β€” add one with `torii remote link <name> --url <url>`)".to_string()
955            } else {
956                configured.join(", ")
957            };
958            return Err(crate::error::ToriiError::InvalidConfig(format!(
959                "no remote '{}' configured. Configured remotes: {}", name, list
960            )));
961        }
962        println!("πŸ”„ Fetching from '{}'...", name);
963        self.fetch_one(name)?;
964        println!("βœ… Fetched from '{}'", name);
965        Ok(())
966    }
967
968    /// Fetch from every configured remote. Prints one line per remote
969    /// with status, returns Err if any single remote failed (the others
970    /// still get attempted before the error surfaces).
971    pub fn fetch_all(&self) -> Result<()> {
972        let names: Vec<String> = self.repo.remotes()
973            .map_err(|e| crate::error::ToriiError::Git(e))?
974            .iter().flatten().map(String::from).collect();
975        if names.is_empty() {
976            return Err(crate::error::ToriiError::InvalidConfig(
977                "no remotes configured. Add one with `torii remote link <name> --url <url>`".to_string()
978            ));
979        }
980        println!("πŸ”„ Fetching from {} remote(s)...", names.len());
981        let mut failures: Vec<(String, String)> = Vec::new();
982        for name in &names {
983            match self.fetch_one(name) {
984                Ok(()) => println!("  βœ… {}", name),
985                Err(e) => {
986                    println!("  ❌ {}: {}", name, e);
987                    failures.push((name.clone(), e.to_string()));
988                }
989            }
990        }
991        if failures.is_empty() {
992            println!("βœ… Fetched from all {} remote(s)", names.len());
993            Ok(())
994        } else {
995            Err(crate::error::ToriiError::Network { provider: "remotes".into(), message: format!(
996                "{}/{} remote(s) failed to fetch: {}",
997                failures.len(), names.len(),
998                failures.iter().map(|(n,_)| n.as_str()).collect::<Vec<_>>().join(", ")
999            ) })
1000        }
1001    }
1002
1003    /// Shared fetch implementation: refspec defaults to whatever is in
1004    /// `.git/config` for this remote (libgit2 reads it when an empty
1005    /// slice is passed). Honors auth callbacks + progress display.
1006    fn fetch_one(&self, name: &str) -> Result<()> {
1007        let mut remote = self.repo.find_remote(name)
1008            .map_err(|e| crate::error::ToriiError::Git(e))?;
1009        let remote_url = remote.url().unwrap_or("").to_string();
1010        let mut callbacks = GitRepo::auth_callbacks_for(&remote_url);
1011        GitRepo::attach_fetch_progress(&mut callbacks);
1012        let mut fetch_options = git2::FetchOptions::new();
1013        fetch_options.remote_callbacks(callbacks);
1014        remote.fetch(&[] as &[&str], Some(&mut fetch_options), None)
1015            .map_err(|e| crate::error::ToriiError::Git(e))?;
1016        Ok(())
1017    }
1018
1019    /// Revert a specific commit
1020    pub fn revert_commit(&self, commit_hash: &str) -> Result<()> {
1021        println!("πŸ”„ Reverting commit {}...", commit_hash);
1022
1023        let commit = self.repo.revparse_single(commit_hash)
1024            .map_err(|e| crate::error::ToriiError::Git(e))?
1025            .peel_to_commit()
1026            .map_err(|e| crate::error::ToriiError::Git(e))?;
1027
1028        self.repo.revert(&commit, None)
1029            .map_err(|e| crate::error::ToriiError::Git(e))?;
1030
1031        // Commit the revert
1032        let sig = crate::core::resolve_signature(&self.repo)?;
1033        let mut index = self.repo.index()
1034            .map_err(|e| crate::error::ToriiError::Git(e))?;
1035        let tree_oid = index.write_tree()
1036            .map_err(|e| crate::error::ToriiError::Git(e))?;
1037        let tree = self.repo.find_tree(tree_oid)
1038            .map_err(|e| crate::error::ToriiError::Git(e))?;
1039        let head = self.repo.head()
1040            .map_err(|e| crate::error::ToriiError::Git(e))?
1041            .peel_to_commit()
1042            .map_err(|e| crate::error::ToriiError::Git(e))?;
1043        let msg = format!("Revert \"{}\"", commit.summary().unwrap_or(commit_hash));
1044        crate::core::commit_inner(&self.repo, Some("HEAD"), &sig, &msg, &tree, &[&head])?;
1045
1046        println!("βœ… Reverted commit {}", &commit_hash[..7.min(commit_hash.len())]);
1047        Ok(())
1048    }
1049
1050    /// Reset to a specific commit
1051    pub fn reset_commit(&self, commit_hash: &str, mode: &str) -> Result<()> {
1052        println!("πŸ”„ Resetting to commit {} (mode: {})...", commit_hash, mode);
1053
1054        let obj = self.repo.revparse_single(commit_hash)
1055            .map_err(|e| crate::error::ToriiError::Git(e))?;
1056        let commit = obj.peel_to_commit()
1057            .map_err(|e| crate::error::ToriiError::Git(e))?;
1058
1059        let reset_type = match mode {
1060            "soft" => git2::ResetType::Soft,
1061            "hard" => git2::ResetType::Hard,
1062            _ => git2::ResetType::Mixed,
1063        };
1064
1065        self.repo.reset(commit.as_object(), reset_type, None)
1066            .map_err(|e| crate::error::ToriiError::Git(e))?;
1067
1068        let short = commit.id().to_string();
1069        println!("βœ… Reset to {}", &short[..7]);
1070        Ok(())
1071    }
1072
1073    /// Merge a branch into current branch
1074    pub fn merge_branch(&self, branch_name: &str) -> Result<()> {
1075        let branch_ref = format!("refs/heads/{}", branch_name);
1076        let annotated = self.repo.find_reference(&branch_ref)
1077            .map_err(|e| crate::error::ToriiError::Git(e))
1078            .and_then(|r| self.repo.reference_to_annotated_commit(&r)
1079                .map_err(|e| crate::error::ToriiError::Git(e)))?;
1080
1081        let (analysis, _) = self.repo.merge_analysis(&[&annotated])
1082            .map_err(|e| crate::error::ToriiError::Git(e))?;
1083
1084        if analysis.is_up_to_date() {
1085            println!("Already up to date.");
1086            return Ok(());
1087        }
1088
1089        if analysis.is_fast_forward() {
1090            let refname = format!("refs/heads/{}", self.get_current_branch()?);
1091            let mut reference = self.repo.find_reference(&refname)
1092                .map_err(|e| crate::error::ToriiError::Git(e))?;
1093            reference.set_target(annotated.id(), "Fast-forward")
1094                .map_err(|e| crate::error::ToriiError::Git(e))?;
1095            self.repo.set_head(&refname)
1096                .map_err(|e| crate::error::ToriiError::Git(e))?;
1097            self.repo.checkout_head(Some(git2::build::CheckoutBuilder::default().force()))
1098                .map_err(|e| crate::error::ToriiError::Git(e))?;
1099            println!("βœ… Fast-forward merged {}", branch_name);
1100        } else {
1101            // Normal merge commit
1102            self.repo.merge(&[&annotated], None, None)
1103                .map_err(|e| crate::error::ToriiError::Git(e))?;
1104
1105            let mut index = self.repo.index()
1106                .map_err(|e| crate::error::ToriiError::Git(e))?;
1107            if index.has_conflicts() {
1108                println!("⚠️  Merge conflicts detected. Resolve them and run: torii save -m \"merge\"");
1109                return Ok(());
1110            }
1111
1112            let tree_oid = index.write_tree()
1113                .map_err(|e| crate::error::ToriiError::Git(e))?;
1114            let tree = self.repo.find_tree(tree_oid)
1115                .map_err(|e| crate::error::ToriiError::Git(e))?;
1116            let sig = crate::core::resolve_signature(&self.repo)?;
1117            let head = self.repo.head()
1118                .map_err(|e| crate::error::ToriiError::Git(e))?
1119                .peel_to_commit()
1120                .map_err(|e| crate::error::ToriiError::Git(e))?;
1121            let branch_commit = self.repo.find_reference(&branch_ref)
1122                .map_err(|e| crate::error::ToriiError::Git(e))?
1123                .peel_to_commit()
1124                .map_err(|e| crate::error::ToriiError::Git(e))?;
1125            let msg = format!("Merge branch '{}'", branch_name);
1126            crate::core::commit_inner(&self.repo, Some("HEAD"), &sig, &msg, &tree, &[&head, &branch_commit])?;
1127            self.repo.cleanup_state()
1128                .map_err(|e| crate::error::ToriiError::Git(e))?;
1129
1130            println!("βœ… Merged {}", branch_name);
1131        }
1132
1133        Ok(())
1134    }
1135
1136    /// Rebase current branch onto another branch
1137    pub fn rebase_branch(&self, branch_name: &str) -> Result<()> {
1138        // git2's Rebase API is available β€” use it for non-interactive rebase
1139        let branch_ref = format!("refs/heads/{}", branch_name);
1140        let upstream = self.repo.find_reference(&branch_ref)
1141            .map_err(|e| crate::error::ToriiError::Git(e))
1142            .and_then(|r| self.repo.reference_to_annotated_commit(&r)
1143                .map_err(|e| crate::error::ToriiError::Git(e)))?;
1144
1145        let mut rebase = self.repo.rebase(None, Some(&upstream), None, None)
1146            .map_err(|e| crate::error::ToriiError::Git(e))?;
1147
1148        let sig = crate::core::resolve_signature(&self.repo)?;
1149
1150        while let Some(op) = rebase.next() {
1151            op.map_err(|e| crate::error::ToriiError::Git(e))?;
1152            let index = self.repo.index()
1153                .map_err(|e| crate::error::ToriiError::Git(e))?;
1154            if index.has_conflicts() {
1155                println!("⚠️  Rebase conflict. Resolve conflicts and run: torii history rebase --continue");
1156                return Ok(());
1157            }
1158            rebase.commit(None, &sig, None)
1159                .map_err(|e| crate::error::ToriiError::Git(e))?;
1160        }
1161
1162        rebase.finish(Some(&sig))
1163            .map_err(|e| crate::error::ToriiError::Git(e))?;
1164
1165        println!("βœ… Rebased onto {}", branch_name);
1166        Ok(())
1167    }
1168
1169    /// List all tracked files in the index
1170    #[allow(dead_code)]
1171    pub fn ls(&self, path_filter: Option<&str>) -> Result<()> {
1172        let mut index = self.repo.index()?;
1173        index.read(true)?;
1174
1175        let entries: Vec<_> = index.iter()
1176            .filter(|e| {
1177                let path = String::from_utf8_lossy(&e.path).to_string();
1178                match path_filter {
1179                    Some(filter) => path.starts_with(filter),
1180                    None => true,
1181                }
1182            })
1183            .collect();
1184
1185        if entries.is_empty() {
1186            println!("No tracked files.");
1187            return Ok(());
1188        }
1189
1190        for entry in &entries {
1191            let path = String::from_utf8_lossy(&entry.path);
1192            println!("{}", path);
1193        }
1194
1195        println!();
1196        println!("{} tracked file(s)", entries.len());
1197
1198        Ok(())
1199    }
1200
1201    /// Show details of a commit, tag, or file at a given ref
1202    pub fn show(&self, object: Option<&str>) -> Result<()> {
1203        // Use the ref or default to HEAD
1204        let target = object.unwrap_or("HEAD");
1205
1206        // Try to resolve as commit first
1207        let resolved = self.repo.revparse_single(target);
1208
1209        match resolved {
1210            Ok(obj) => {
1211                match obj.kind() {
1212                    Some(git2::ObjectType::Commit) => {
1213                        let commit = obj.peel_to_commit()?;
1214                        let sig = commit.author();
1215                        let time = commit.time();
1216                        let timestamp = chrono::DateTime::from_timestamp(time.seconds(), 0)
1217                            .unwrap_or_default();
1218
1219                        println!("commit {}", commit.id());
1220                        println!("Author: {} <{}>", sig.name().unwrap_or(""), sig.email().unwrap_or(""));
1221                        println!("Date:   {}", timestamp.format("%Y-%m-%d %H:%M:%S"));
1222                        println!();
1223                        println!("    {}", commit.message().unwrap_or("").trim());
1224                        println!();
1225
1226                        // Show diff vs parent via git2
1227                        let commit_tree = commit.tree().ok();
1228                        let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok());
1229                        if let Some(new_tree) = commit_tree {
1230                            let diff = self.repo.diff_tree_to_tree(
1231                                parent_tree.as_ref(),
1232                                Some(&new_tree),
1233                                None,
1234                            )?;
1235                            diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
1236                                let origin = line.origin();
1237                                let content = std::str::from_utf8(line.content()).unwrap_or("");
1238                                match origin {
1239                                    '+' => print!("\x1b[32m+{}\x1b[0m", content),
1240                                    '-' => print!("\x1b[31m-{}\x1b[0m", content),
1241                                    'H' | 'F' => print!("{}", content),
1242                                    _ => print!(" {}", content),
1243                                }
1244                                true
1245                            })?;
1246                        }
1247                    }
1248                    Some(git2::ObjectType::Tag) => {
1249                        let tag = obj.peel_to_tag()?;
1250                        println!("tag {}", tag.name().unwrap_or(""));
1251                        if let Some(tagger) = tag.tagger() {
1252                            println!("Tagger: {} <{}>", tagger.name().unwrap_or(""), tagger.email().unwrap_or(""));
1253                        }
1254                        println!();
1255                        println!("{}", tag.message().unwrap_or("").trim());
1256                    }
1257                    Some(git2::ObjectType::Blob) => {
1258                        let blob = obj.peel_to_blob()?;
1259                        let content = std::str::from_utf8(blob.content())
1260                            .unwrap_or("<binary>");
1261                        print!("{}", content);
1262                    }
1263                    _ => {
1264                        println!("{}", obj.id());
1265                    }
1266                }
1267            }
1268            Err(_) => {
1269                return Err(crate::error::ToriiError::Usage(
1270                    format!("Unknown ref or object: '{}'", target)
1271                ).into());
1272            }
1273        }
1274
1275        Ok(())
1276    }
1277}
1278
1279/// 0.7.35 β€” one-letter signature verdict for `log --signatures`.
1280/// libgit2's `commit_extract_signature` returns both the armor and the
1281/// raw signed payload; we hand them to `crate::gpg::verify` and bucket
1282/// the result. Verifies against the local keyring, so an unfamiliar
1283/// signer shows as `U` (unknown), not `B` (bad).
1284// Porcelain tier (see `core::commit_inner`): raw `git2` params for the TUI.
1285#[doc(hidden)]
1286pub fn signature_letter(repo: &git2::Repository, oid: git2::Oid) -> &'static str {
1287    let (sig_buf, payload_buf) = match repo.extract_signature(&oid, None) {
1288        Ok(pair) => pair,
1289        Err(_)   => return "N",
1290    };
1291    let sig_bytes: &[u8] = &sig_buf;
1292    let payload: Vec<u8> = (&*payload_buf).to_vec();
1293    let armor = match std::str::from_utf8(sig_bytes) {
1294        Ok(s) => s.to_string(),
1295        Err(_) => return "B",
1296    };
1297
1298    let program = repo.workdir()
1299        .and_then(|wd| crate::config::ToriiConfig::load_local(wd).ok())
1300        .and_then(|c| c.git.gpg_program);
1301
1302    match crate::gpg::verify(&armor, &payload, program.as_deref()) {
1303        Ok(crate::gpg::VerifyStatus::Good { .. })          => "G",
1304        Ok(crate::gpg::VerifyStatus::UnknownKey { .. })    => "U",
1305        Ok(crate::gpg::VerifyStatus::Bad)                  => "B",
1306        Ok(crate::gpg::VerifyStatus::Other(_)) | Err(_)    => "?",
1307    }
1308}
1309
1310fn remove_path_from_tree(repo: &git2::Repository, tree: &git2::Tree, path: &str) -> crate::error::Result<git2::Oid> {
1311    let mut builder = repo.treebuilder(Some(tree))
1312        .map_err(|e| crate::error::ToriiError::Git(e))?;
1313
1314    let parts: Vec<&str> = path.splitn(2, '/').collect();
1315    if parts.len() == 1 {
1316        // Leaf β€” just remove it
1317        let _ = builder.remove(parts[0]);
1318    } else {
1319        let dir = parts[0];
1320        let rest = parts[1];
1321        if let Ok(entry) = tree.get_name(dir).ok_or(git2::Error::from_str("not found")) {
1322            if let Ok(sub_tree) = repo.find_tree(entry.id()) {
1323                let new_sub_oid = remove_path_from_tree(repo, &sub_tree, rest)?;
1324                let new_sub = repo.find_tree(new_sub_oid)
1325                    .map_err(|e| crate::error::ToriiError::Git(e))?;
1326                if new_sub.is_empty() {
1327                    let _ = builder.remove(dir);
1328                } else {
1329                    builder.insert(dir, new_sub_oid, 0o040000)
1330                        .map_err(|e| crate::error::ToriiError::Git(e))?;
1331                }
1332            }
1333        }
1334    }
1335
1336    builder.write().map_err(|e| crate::error::ToriiError::Git(e))
1337}
1338
1339fn remove_dir_contents(dir: &std::path::Path) -> std::io::Result<()> {
1340    for entry in std::fs::read_dir(dir)? {
1341        let entry = entry?;
1342        let path = entry.path();
1343        if path.is_dir() {
1344            remove_dir_contents(&path)?;
1345            let _ = std::fs::remove_dir(&path);
1346        } else {
1347            let _ = std::fs::remove_file(&path);
1348        }
1349    }
1350    Ok(())
1351}
1352
1353// ============================================================================
1354// Rebase helpers β€” todo-file preprocessing + outcome detection
1355// ============================================================================
1356
1357/// Read a torii todo file and emit two artefacts:
1358///   1. A new todo file safe to feed to `git rebase -i` (every `reword` line
1359///      becomes `pick`; we'll edit the message ourselves via GIT_EDITOR).
1360///   2. A map { full_sha β†’ new_message } harvested from `reword <sha> <msg>`
1361///      entries. Lines without an inline message keep the original commit
1362///      subject so behaviour matches a no-op `pick`.
1363///
1364/// torii-only extension to git's todo grammar:
1365///   reword <sha> <new commit message on rest of line>
1366///
1367/// Standard git todo lines (`pick`, `edit`, `squash`, `fixup`, `drop`,
1368/// `exec ...`, etc.) pass through unchanged.
1369#[cfg(unix)]
1370fn preprocess_reword_todo(
1371    src: &std::path::Path,
1372) -> Result<(std::path::PathBuf, std::collections::HashMap<String, String>)> {
1373    let raw = std::fs::read_to_string(src).map_err(|e| {
1374        crate::error::ToriiError::Fs(format!("read todo {}: {}", src.display(), e))
1375    })?;
1376
1377    let mut out_lines: Vec<String> = Vec::with_capacity(raw.lines().count());
1378    let mut reword_map: std::collections::HashMap<String, String> =
1379        std::collections::HashMap::new();
1380
1381    for line in raw.lines() {
1382        let trimmed = line.trim_start();
1383        if trimmed.is_empty() || trimmed.starts_with('#') {
1384            out_lines.push(line.to_string());
1385            continue;
1386        }
1387        let mut parts = trimmed.splitn(3, ' ');
1388        let cmd = parts.next().unwrap_or("");
1389        if cmd != "reword" && cmd != "r" {
1390            out_lines.push(line.to_string());
1391            continue;
1392        }
1393        let sha = match parts.next() {
1394            Some(s) => s.to_string(),
1395            None => {
1396                out_lines.push(line.to_string());
1397                continue;
1398            }
1399        };
1400        let inline_msg = parts.next().unwrap_or("").trim().to_string();
1401        if !inline_msg.is_empty() {
1402            reword_map.insert(sha.clone(), inline_msg);
1403            // Keep `reword` so git invokes GIT_EDITOR; our shim replaces msg.
1404            out_lines.push(format!("reword {}", sha));
1405        } else {
1406            // No inline message β†’ behave like a noop reword. Use `pick` so git
1407            // doesn't pause asking for a message we can't supply.
1408            out_lines.push(format!("pick {}", sha));
1409        }
1410    }
1411
1412    let dest = std::env::temp_dir().join(format!(
1413        "torii-rebase-todo-{}.txt",
1414        std::process::id()
1415    ));
1416    std::fs::write(&dest, out_lines.join("\n") + "\n").map_err(|e| {
1417        crate::error::ToriiError::Fs(format!("write todo: {}", e))
1418    })?;
1419
1420    Ok((dest, reword_map))
1421}
1422
1423/// Install a GIT_EDITOR shim so that `reword` lines from the torii todo file
1424/// take effect.
1425///
1426/// Strategy: write a tiny shell script that, given the COMMIT_EDITMSG file
1427/// path, reads the *original* subject line from the file (git puts the
1428/// pre-reword message there), looks it up in our `subject β†’ new_msg` map,
1429/// and if matched replaces the file content. Otherwise leaves untouched.
1430///
1431/// Matching by subject (rather than SHA) is robust to rebase rewriting
1432/// parent SHAs as it goes β€” git's HEAD changes during the rebase, but the
1433/// subject of the message-being-edited is the original.
1434#[cfg(unix)]
1435fn install_message_editor(
1436    cmd: &mut std::process::Command,
1437    reword_map: &std::collections::HashMap<String, String>,
1438    repo_path: &std::path::Path,
1439) -> Result<()> {
1440    if reword_map.is_empty() {
1441        return Ok(());
1442    }
1443
1444    // Resolve each todo-file sha to the original commit subject. The map is
1445    // (subject β†’ new message). Tabs + newlines in the new message are encoded.
1446    let mut subject_map: Vec<(String, String)> = Vec::with_capacity(reword_map.len());
1447    for (sha, new_msg) in reword_map {
1448        let subj = std::process::Command::new("git")
1449            .args(["log", "-1", "--format=%s", sha])
1450            .current_dir(repo_path)
1451            .output();
1452        let subject = match subj {
1453            Ok(o) if o.status.success() => {
1454                String::from_utf8_lossy(&o.stdout).trim().to_string()
1455            }
1456            _ => continue, // unknown sha β†’ skip silently (rebase may still proceed)
1457        };
1458        if subject.is_empty() {
1459            continue;
1460        }
1461        subject_map.push((subject, new_msg.clone()));
1462    }
1463    if subject_map.is_empty() {
1464        return Ok(());
1465    }
1466
1467    let map_path = std::env::temp_dir().join(format!(
1468        "torii-rebase-rewords-{}.tsv",
1469        std::process::id()
1470    ));
1471    let mut map_text = String::new();
1472    for (subj, msg) in &subject_map {
1473        let safe_subj = subj.replace('\\', "\\\\").replace('\t', " ");
1474        let safe_msg = msg.replace('\\', "\\\\").replace('\n', "\\n").replace('\t', "    ");
1475        map_text.push_str(&format!("{}\t{}\n", safe_subj, safe_msg));
1476    }
1477    std::fs::write(&map_path, &map_text).map_err(|e| {
1478        crate::error::ToriiError::Fs(format!("write reword map: {}", e))
1479    })?;
1480
1481    let shim_path = std::env::temp_dir().join(format!(
1482        "torii-rebase-editor-{}.sh",
1483        std::process::id()
1484    ));
1485    let shim_body = format!(
1486        r#"#!/bin/sh
1487# torii-generated rebase message editor.
1488# Usage: GIT_EDITOR <commit-msg-file>
1489set -e
1490MSG_FILE="$1"
1491[ -z "$MSG_FILE" ] && exit 0
1492MAP="{map}"
1493[ ! -f "$MAP" ] && exit 0
1494# Read the first non-comment line of the message β€” that's the subject.
1495SUBJ=$(awk '/^#/ {{ next }} /./ {{ print; exit }}' "$MSG_FILE")
1496[ -z "$SUBJ" ] && exit 0
1497while IFS=$(printf '\t') read -r KEY MSG; do
1498    if [ "$KEY" = "$SUBJ" ]; then
1499        printf '%b\n' "$(printf '%s' "$MSG" | sed 's/\\n/\n/g; s/\\\\/\\/g')" > "$MSG_FILE"
1500        exit 0
1501    fi
1502done < "$MAP"
1503exit 0
1504"#,
1505        map = map_path.display(),
1506    );
1507    std::fs::write(&shim_path, shim_body).map_err(|e| {
1508        crate::error::ToriiError::Fs(format!("write shim: {}", e))
1509    })?;
1510    use std::os::unix::fs::PermissionsExt;
1511    let mut perms = std::fs::metadata(&shim_path)
1512        .map_err(|e| crate::error::ToriiError::Fs(format!("shim perms: {}", e)))?
1513        .permissions();
1514    perms.set_mode(0o755);
1515    let _ = std::fs::set_permissions(&shim_path, perms);
1516
1517    cmd.env("GIT_EDITOR", &shim_path);
1518    Ok(())
1519}
1520
1521/// Inspect the rebase state directory after `git rebase` returned. If a rebase
1522/// is still in progress (because of `edit`, conflicts, or `break`), surface a
1523/// clear next-step message instead of the misleading "Rebase complete".
1524fn report_rebase_outcome(repo_path: &std::path::Path, status: std::process::ExitStatus) {
1525    let merge_dir = repo_path.join(".git").join("rebase-merge");
1526    let apply_dir = repo_path.join(".git").join("rebase-apply");
1527    let in_progress = merge_dir.exists() || apply_dir.exists();
1528
1529    if in_progress {
1530        let stopped_sha = std::fs::read_to_string(merge_dir.join("stopped-sha"))
1531            .or_else(|_| std::fs::read_to_string(apply_dir.join("stopped-sha")))
1532            .ok()
1533            .map(|s| s.trim().to_string())
1534            .unwrap_or_default();
1535        eprintln!();
1536        if stopped_sha.is_empty() {
1537            eprintln!("⏸️  Rebase paused.");
1538        } else {
1539            eprintln!("⏸️  Rebase paused at {}.", &stopped_sha[..stopped_sha.len().min(7)]);
1540        }
1541        eprintln!("    Edit files / amend / cherry-pick as needed, then:");
1542        eprintln!("      torii history rebase --continue");
1543        eprintln!("    Or abort with:");
1544        eprintln!("      torii history rebase --abort");
1545        return;
1546    }
1547
1548    if !status.success() {
1549        eprintln!("⚠️  Rebase ended with conflicts or was aborted.");
1550        return;
1551    }
1552    println!("βœ… Rebase complete");
1553}
1554
1555/// Live checkout progress (file count + path). Used by branch switches and
1556/// other working-tree updates. Throttled to ~10 fps.
1557fn attach_checkout_progress(builder: &mut git2::build::CheckoutBuilder) {
1558    use std::cell::RefCell;
1559    use std::io::Write;
1560    use std::time::Instant;
1561
1562    let last_print = RefCell::new(Instant::now());
1563    builder.progress(move |path, completed, total| {
1564        let mut last = last_print.borrow_mut();
1565        let done = total > 0 && completed >= total;
1566        if !done && last.elapsed().as_millis() < 100 {
1567            return;
1568        }
1569        *last = Instant::now();
1570
1571        let pct = if total > 0 { completed * 100 / total } else { 0 };
1572        let name = path
1573            .and_then(|p| p.file_name())
1574            .map(|n| n.to_string_lossy().into_owned())
1575            .unwrap_or_default();
1576        print!("\rπŸ”€ {pct}%  {completed}/{total} files  {name:<40}");
1577        std::io::stdout().flush().ok();
1578        if done {
1579            println!();
1580        }
1581    });
1582}
1583
1584#[cfg(test)]
1585mod fetch_tests {
1586    use super::*;
1587    use std::path::Path;
1588    use tempfile::TempDir;
1589
1590    fn make_source_repo(dir: &Path) -> git2::Repository {
1591        let repo = git2::Repository::init_bare(dir).unwrap();
1592        {
1593            let sig = git2::Signature::now("Test", "t@x").unwrap();
1594            let mut idx = repo.index().unwrap();
1595            let tree_oid = idx.write_tree().unwrap();
1596            let tree = repo.find_tree(tree_oid).unwrap();
1597            repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]).unwrap();
1598        }
1599        repo
1600    }
1601
1602    fn make_consumer_repo(dir: &Path) -> GitRepo {
1603        git2::Repository::init(dir).unwrap();
1604        GitRepo::open(dir).unwrap()
1605    }
1606
1607    #[test]
1608    fn fetch_named_pulls_refs_into_remotes_namespace() {
1609        let tmp = TempDir::new().unwrap();
1610        let src = tmp.path().join("src.git");
1611        let _ = make_source_repo(&src);
1612        let consumer = tmp.path().join("consumer");
1613        let gitorii = make_consumer_repo(&consumer);
1614        gitorii.repo.remote("upstream", &format!("file://{}", src.display())).unwrap();
1615
1616        gitorii.fetch_named("upstream").unwrap();
1617
1618        // refs/remotes/upstream/HEAD or refs/remotes/upstream/master should exist
1619        let mut found = false;
1620        for r in gitorii.repo.references().unwrap().flatten() {
1621            if r.name().unwrap_or("").starts_with("refs/remotes/upstream/") {
1622                found = true; break;
1623            }
1624        }
1625        assert!(found, "expected refs/remotes/upstream/* after fetch_named");
1626    }
1627
1628    #[test]
1629    fn fetch_named_missing_remote_errors_with_hint() {
1630        let tmp = TempDir::new().unwrap();
1631        let consumer = tmp.path().join("consumer");
1632        let gitorii = make_consumer_repo(&consumer);
1633        gitorii.repo.remote("origin", "file:///nowhere").unwrap();
1634
1635        let err = gitorii.fetch_named("upstream").unwrap_err().to_string();
1636        assert!(err.contains("upstream"), "error should name the missing remote: {}", err);
1637        assert!(err.contains("origin"), "error should list configured remotes: {}", err);
1638    }
1639
1640    #[test]
1641    fn fetch_all_iterates_every_remote() {
1642        let tmp = TempDir::new().unwrap();
1643        let src_a = tmp.path().join("a.git");
1644        let src_b = tmp.path().join("b.git");
1645        let _ = make_source_repo(&src_a);
1646        let _ = make_source_repo(&src_b);
1647        let consumer = tmp.path().join("consumer");
1648        let gitorii = make_consumer_repo(&consumer);
1649        gitorii.repo.remote("a", &format!("file://{}", src_a.display())).unwrap();
1650        gitorii.repo.remote("b", &format!("file://{}", src_b.display())).unwrap();
1651
1652        gitorii.fetch_all().unwrap();
1653
1654        let mut a_seen = false;
1655        let mut b_seen = false;
1656        for r in gitorii.repo.references().unwrap().flatten() {
1657            let n = r.name().unwrap_or("");
1658            if n.starts_with("refs/remotes/a/") { a_seen = true; }
1659            if n.starts_with("refs/remotes/b/") { b_seen = true; }
1660        }
1661        assert!(a_seen && b_seen, "fetch_all should populate both remotes");
1662    }
1663
1664    #[test]
1665    fn fetch_all_with_no_remotes_errors() {
1666        let tmp = TempDir::new().unwrap();
1667        let consumer = tmp.path().join("consumer");
1668        let gitorii = make_consumer_repo(&consumer);
1669
1670        let err = gitorii.fetch_all().unwrap_err().to_string();
1671        assert!(err.contains("no remotes"), "error should mention missing remotes: {}", err);
1672    }
1673}