Skip to main content

torii_lib/vcs/
core.rs

1use git2::{Repository, Signature, IndexAddOption, StatusOptions};
2use std::path::{Path, PathBuf};
3use crate::error::{Result, ToriiError};
4
5pub struct GitRepo {
6    pub(crate) repo: Repository,
7}
8
9impl GitRepo {
10    /// Initialize a new git repository.
11    ///
12    /// Sets the initial branch from `git.default_branch` in the global torii
13    /// config (default `main`) instead of libgit2's hard-coded `master`.
14    pub fn init<P: AsRef<Path>>(path: P) -> Result<Self> {
15        let initial = crate::config::ToriiConfig::load_global()
16            .map(|c| c.git.default_branch)
17            .unwrap_or_else(|_| "main".to_string());
18        let mut opts = git2::RepositoryInitOptions::new();
19        opts.initial_head(&initial);
20        let repo = Repository::init_opts(path, &opts)?;
21        Ok(Self { repo })
22    }
23
24    /// Open an existing repository
25    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
26        let path_ref = path.as_ref();
27        let repo = Repository::discover(path_ref)
28            .map_err(|_| ToriiError::RepositoryNotFound(
29                path_ref.display().to_string()
30            ))?;
31        let git_repo = Self { repo };
32        // Sync .toriignore on every open so all git operations respect it
33        git_repo.sync_toriignore()?;
34        Ok(git_repo)
35    }
36
37    /// Sync .toriignore (+ .toriignore.local) → .git/info/exclude so git
38    /// itself respects the patterns. Always force-excludes `.toriignore.local`
39    /// itself — local rules are machine-private and must never be committed.
40    /// Called automatically on open and before staging.
41    pub fn sync_toriignore(&self) -> Result<()> {
42        // .git/ always has a parent (the work tree) for non-bare repos.
43        let repo_path = self.repo.path().parent()
44            .ok_or_else(|| crate::error::ToriiError::RepoState("git directory has no parent (bare repo?)".to_string()))?
45            .to_path_buf();
46        let public_path = repo_path.join(".toriignore");
47        let local_path = repo_path.join(".toriignore.local");
48        let exclude_path = self.repo.path().join("info").join("exclude");
49
50        let mut buf = String::from(
51            "# Synced from .toriignore by torii — do not edit manually\n\
52             # Local-only rules — never commit\n\
53             .toriignore.local\n",
54        );
55
56        if public_path.exists() {
57            if let Ok(content) = std::fs::read_to_string(&public_path) {
58                buf.push_str(&content);
59                if !buf.ends_with('\n') { buf.push('\n'); }
60            }
61        }
62
63        if local_path.exists() {
64            if let Ok(content) = std::fs::read_to_string(&local_path) {
65                buf.push_str("# ─── from .toriignore.local ───\n");
66                buf.push_str(&content);
67            }
68        }
69
70        std::fs::write(&exclude_path, buf)
71            .map_err(|e| ToriiError::Fs(format!("write {}: {}", exclude_path.display(), e)))?;
72        Ok(())
73    }
74
75    /// Add all changes to staging, respecting .toriignore.
76    ///
77    /// 0.7.7: `.torii/` is treated as reserved internal state and is
78    /// never staged by `-a`, the same way `git add .` skips `.git/`.
79    /// Before 0.7.7 snapshots lived in `.torii/snapshots/` inside the
80    /// working tree, and a follow-up `torii save -am` silently
81    /// absorbed the entire snapshot (one case in the wild was 681 MB
82    /// pushed to origin before the receiving end aborted with a zlib
83    /// stream error). Fix #1 in 0.7.7 moved snapshots out of the
84    /// working tree; this skip is the defense-in-depth so anything
85    /// else under `.torii/` (config.json, mirrors.json) is also kept
86    /// out of `-a`. To stage a path under `.torii/` deliberately,
87    /// pass it explicitly: `torii save .torii/config.json -m "..."`.
88    pub fn add_all(&self) -> Result<()> {
89        self.sync_toriignore()?;
90
91        let mut index = self.repo.index()?;
92        let mut skipped_torii = false;
93        let cb = &mut |path: &Path, _matched: &[u8]| -> i32 {
94            let s = path.to_string_lossy();
95            if s == ".torii" || s.starts_with(".torii/") || s.starts_with(".torii\\") {
96                skipped_torii = true;
97                1 // skip
98            } else {
99                0 // add
100            }
101        };
102        index.add_all(["*"].iter(), IndexAddOption::DEFAULT, Some(cb as &mut git2::IndexMatchedPath<'_>))?;
103        index.write()?;
104        if skipped_torii {
105            eprintln!("ℹ Skipped `.torii/` from staging (reserved for torii internal state). \
106                       Pass paths explicitly if you really want to stage something inside it.");
107        }
108        Ok(())
109    }
110
111    /// Add specific files to staging
112    pub fn add<P: AsRef<Path>>(&self, paths: &[P]) -> Result<()> {
113        let mut index = self.repo.index()?;
114        for path in paths {
115            index.add_path(path.as_ref())?;
116        }
117        index.write()?;
118        Ok(())
119    }
120
121    /// Unstage paths — equivalent to `git reset HEAD -- <paths>` (or `git rm --cached`
122    /// for files that were never committed). Keeps files on disk.
123    pub fn unstage<P: AsRef<Path>>(&self, paths: &[P]) -> Result<()> {
124        match self.repo.head() {
125            Ok(head) => {
126                let head_obj = head.peel(git2::ObjectType::Commit)?;
127                let path_refs: Vec<&Path> = paths.iter().map(|p| p.as_ref()).collect();
128                self.repo.reset_default(Some(&head_obj), path_refs.iter())?;
129            }
130            Err(_) => {
131                // No HEAD yet (root commit not made) — drop entries from index directly
132                let mut index = self.repo.index()?;
133                for path in paths {
134                    let _ = index.remove_path(path.as_ref());
135                }
136                index.write()?;
137            }
138        }
139        Ok(())
140    }
141
142    /// Unstage all paths currently in the index.
143    pub fn unstage_all(&self) -> Result<()> {
144        let index = self.repo.index()?;
145        let paths: Vec<PathBuf> = index
146            .iter()
147            .filter_map(|e| std::str::from_utf8(&e.path).ok().map(PathBuf::from))
148            .collect();
149        if paths.is_empty() {
150            return Ok(());
151        }
152        self.unstage(&paths)
153    }
154
155    /// Commit changes
156    pub fn commit(&self, message: &str) -> Result<()> {
157        let sig = self.get_signature()?;
158        let mut index = self.repo.index()?;
159        let tree_id = index.write_tree()?;
160        let tree = self.repo.find_tree(tree_id)?;
161
162        // Root commit (empty repo) has no parent
163        let parent_commit = match self.repo.head() {
164            Ok(head) => Some(head.peel_to_commit()?),
165            Err(_) => None,
166        };
167
168        let parents: Vec<&git2::Commit> = parent_commit.iter().collect();
169
170        commit_inner(&self.repo, Some("HEAD"), &sig, message, &tree, &parents)?;
171
172        Ok(())
173    }
174
175    /// Amend the previous commit
176    pub fn commit_amend(&self, message: &str) -> Result<()> {
177        let sig = self.get_signature()?;
178        let mut index = self.repo.index()?;
179        let tree_id = index.write_tree()?;
180        let tree = self.repo.find_tree(tree_id)?;
181
182        // Resolve HEAD via the branch ref directly to dodge stale internal state
183        // after operations like history rewrite.
184        let head_ref = self.repo.head()?;
185        let head_oid = head_ref.target()
186            .ok_or_else(|| ToriiError::RepoState("HEAD has no target".to_string()))?;
187        let head_commit = self.repo.find_commit(head_oid)?;
188
189        let parents: Vec<_> = head_commit.parents().collect();
190        let parent_refs: Vec<_> = parents.iter().collect();
191
192        let new_oid = commit_inner(&self.repo, None, &sig, message, &tree, &parent_refs)?;
193
194        // Move HEAD (or the underlying branch ref) to the new commit explicitly,
195        // bypassing libgit2's "first parent" check that fails when HEAD was
196        // rewritten just before this call.
197        if head_ref.is_branch() {
198            if let Some(refname) = head_ref.name() {
199                self.repo.reference(refname, new_oid, true, "amend")?;
200            }
201        } else {
202            self.repo.set_head_detached(new_oid)?;
203        }
204
205        Ok(())
206    }
207    
208    /// Build auth callbacks for SSH and HTTPS token auth.
209    /// Pass the remote URL so the correct token is selected per host.
210    pub(crate) fn auth_callbacks_for<'a>(url: &str) -> git2::RemoteCallbacks<'a> {
211        // Token lookups inside the credentials callback now go through
212        // crate::auth::resolve_token; no global config load needed here.
213        let url_owned = url.to_string();
214        let mut callbacks = git2::RemoteCallbacks::new();
215        callbacks.credentials(move |cb_url, username_from_url, allowed_types| {
216            let effective_url = if url_owned.is_empty() { cb_url } else { &url_owned };
217            if allowed_types.contains(git2::CredentialType::SSH_KEY) {
218                let username = username_from_url.unwrap_or("git");
219                let home = dirs::home_dir().unwrap_or_default();
220                let ed25519 = home.join(".ssh").join("id_ed25519");
221                let rsa = home.join(".ssh").join("id_rsa");
222                if ed25519.exists() {
223                    return git2::Cred::ssh_key(username, None, &ed25519, None);
224                } else if rsa.exists() {
225                    return git2::Cred::ssh_key(username, None, &rsa, None);
226                } else {
227                    return git2::Cred::ssh_key_from_agent(username);
228                }
229            }
230            if allowed_types.contains(git2::CredentialType::USER_PASS_PLAINTEXT) {
231                // Route through the unified token resolver so local
232                // overrides and env vars are honoured here too.
233                let provider = if effective_url.contains("github.com") {
234                    "github"
235                } else if effective_url.contains("gitlab.com") {
236                    "gitlab"
237                } else if effective_url.contains("codeberg.org") {
238                    "codeberg"
239                } else {
240                    "gitea"
241                };
242                if let Some(token) = crate::auth::resolve_token(provider, ".").value {
243                    return git2::Cred::userpass_plaintext("oauth2", &token);
244                }
245            }
246            git2::Cred::default()
247        });
248        callbacks
249    }
250
251    /// Attach progress reporters to an existing `RemoteCallbacks`. Covers:
252    ///   - transfer_progress: pack receive + indexing + delta resolution
253    ///   - sideband_progress: server messages like "Counting objects: …"
254    /// Reused by clone / fetch / pull so every long-running op gives the
255    /// same visual feedback. Throttled to ~10 fps.
256    pub(crate) fn attach_fetch_progress<'a>(callbacks: &mut git2::RemoteCallbacks<'a>) {
257        use std::cell::RefCell;
258        use std::io::Write;
259        use std::time::Instant;
260
261        let last_print = RefCell::new(Instant::now());
262        callbacks.transfer_progress(move |stats| {
263            let mut last = last_print.borrow_mut();
264            let total = stats.total_objects();
265            let recv = stats.received_objects();
266            let idx = stats.indexed_objects();
267            let total_deltas = stats.total_deltas();
268            let idx_deltas = stats.indexed_deltas();
269            let receiving_done = total > 0 && recv == total && idx == total;
270            let deltas_done = total_deltas == 0 || idx_deltas == total_deltas;
271            let done = receiving_done && deltas_done;
272
273            if !done && last.elapsed().as_millis() < 100 {
274                return true;
275            }
276            *last = Instant::now();
277
278            let mb = stats.received_bytes() as f64 / (1024.0 * 1024.0);
279            // Two phases: receiving objects, then resolving deltas.
280            // libgit2 reports both via the same callback, so emit whichever
281            // is currently advancing.
282            if total_deltas > 0 && recv == total {
283                let pct = if total_deltas > 0 { idx_deltas * 100 / total_deltas } else { 100 };
284                print!(
285                    "\r🧩 Resolving deltas {pct}%  {idx_deltas}/{total_deltas}                       "
286                );
287            } else {
288                let pct = if total > 0 { recv * 100 / total } else { 0 };
289                print!(
290                    "\r📥 {pct}%  {recv}/{total} objects  {idx} indexed  {mb:.1} MB       ",
291                );
292            }
293            std::io::stdout().flush().ok();
294            if done {
295                println!();
296            }
297            true
298        });
299        callbacks.sideband_progress(|line| {
300            std::io::stderr().write_all(line).ok();
301            true
302        });
303    }
304
305    /// Attach progress reporters for push (different libgit2 callback set).
306    ///   - push_transfer_progress: pack upload
307    ///   - sideband_progress: server messages
308    /// Throttled to ~10 fps.
309    pub(crate) fn attach_push_progress<'a>(callbacks: &mut git2::RemoteCallbacks<'a>) {
310        use std::cell::RefCell;
311        use std::io::Write;
312        use std::time::Instant;
313
314        let last_print = RefCell::new(Instant::now());
315        callbacks.push_transfer_progress(move |current, total, bytes| {
316            let mut last = last_print.borrow_mut();
317            let done = total > 0 && current == total;
318            if !done && last.elapsed().as_millis() < 100 {
319                return;
320            }
321            *last = Instant::now();
322
323            let pct = if total > 0 { current * 100 / total } else { 0 };
324            let mb = bytes as f64 / (1024.0 * 1024.0);
325            print!("\r📤 {pct}%  {current}/{total} objects  {mb:.1} MB       ");
326            std::io::stdout().flush().ok();
327            if done {
328                println!();
329            }
330        });
331        callbacks.sideband_progress(|line| {
332            std::io::stderr().write_all(line).ok();
333            true
334        });
335    }
336
337    /// Pull from remote (fetch + fast-forward merge of current branch)
338    pub fn pull(&self) -> Result<()> {
339        let branch = self.get_current_branch()?;
340        let mut remote = self.repo.find_remote("origin")?;
341
342        let remote_url = remote.url().unwrap_or("").to_string();
343        let mut callbacks = Self::auth_callbacks_for(&remote_url);
344        Self::attach_fetch_progress(&mut callbacks);
345
346        let mut fetch_options = git2::FetchOptions::new();
347        fetch_options.remote_callbacks(callbacks);
348
349        remote.fetch(&[&branch], Some(&mut fetch_options), None)?;
350
351        // Empty / freshly-created remotes leave FETCH_HEAD as a 0-byte file
352        // and libgit2 then refuses to parse it as a reference. Treat that
353        // as "nothing to pull" — same outcome as up-to-date.
354        let fetch_head_path = self.repo.path().join("FETCH_HEAD");
355        if fetch_head_path.metadata().map(|m| m.len() == 0).unwrap_or(true) {
356            return Ok(());
357        }
358        let fetch_head = self.repo.find_reference("FETCH_HEAD")?;
359        let fetch_commit = self.repo.reference_to_annotated_commit(&fetch_head)?;
360
361        let analysis = self.repo.merge_analysis(&[&fetch_commit])?;
362
363        if analysis.0.is_up_to_date() {
364            return Ok(());
365        }
366        if analysis.0.is_fast_forward() {
367            let refname = format!("refs/heads/{}", branch);
368            let mut reference = self.repo.find_reference(&refname)?;
369            reference.set_target(fetch_commit.id(), "Fast-forward")?;
370            self.repo.set_head(&refname)?;
371            self.repo.checkout_head(Some(git2::build::CheckoutBuilder::default().force()))?;
372            return Ok(());
373        }
374
375        Err(ToriiError::RepoState(format!(
376            "Pull not fast-forward on '{}'. Local and remote diverged. Use 'torii sync {} --merge' or 'torii sync {} --rebase' to integrate.",
377            branch, branch, branch
378        )))
379    }
380
381    /// Push to remote
382    pub fn push(&self, force: bool) -> Result<()> {
383        let mut remote = self.repo.find_remote("origin")?;
384        let branch = self.get_current_branch()?;
385
386        let refspec = if force {
387            format!("+refs/heads/{}:refs/heads/{}", branch, branch)
388        } else {
389            format!("refs/heads/{}:refs/heads/{}", branch, branch)
390        };
391
392        let remote_url = remote.url().unwrap_or("").to_string();
393        let mut callbacks = Self::auth_callbacks_for(&remote_url);
394
395        // Capture per-ref rejections AND track that the callback was actually
396        // fired. libgit2's `remote.push()` can return Ok in three failure
397        // modes our previous fix didn't cover:
398        //   1. Server-side rejection — caught by push_update_reference (msg)
399        //   2. Connection dropped mid-pack on huge pushes — push_update_reference
400        //      *never fires*, so we must also assert it was called at all
401        //   3. SSH transport silently no-ops on auth fail — same: callback skip
402        // We now treat "callback never fired" as failure too, since a real
403        // accepted push always invokes the callback exactly once per refspec.
404        let rejections: std::sync::Arc<std::sync::Mutex<Vec<(String, String)>>> =
405            std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
406        let acknowledged: std::sync::Arc<std::sync::Mutex<Vec<String>>> =
407            std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
408        let rejections_cb = rejections.clone();
409        let acknowledged_cb = acknowledged.clone();
410        callbacks.push_update_reference(move |refname, status| {
411            acknowledged_cb.lock().unwrap().push(refname.to_string());
412            if let Some(msg) = status {
413                rejections_cb
414                    .lock()
415                    .unwrap()
416                    .push((refname.to_string(), msg.to_string()));
417            }
418            Ok(())
419        });
420
421        // Live pack-upload progress + server sideband. Same look as fetch.
422        Self::attach_push_progress(&mut callbacks);
423
424        let mut push_options = git2::PushOptions::new();
425        push_options.remote_callbacks(callbacks);
426
427        // Push branch
428        remote.push(&[&refspec], Some(&mut push_options))?;
429
430        // Surface server-side rejections that libgit2 swallows silently.
431        let rejected = rejections.lock().unwrap();
432        if !rejected.is_empty() {
433            let detail = rejected
434                .iter()
435                .map(|(r, m)| format!("{} → {}", r, m))
436                .collect::<Vec<_>>()
437                .join("; ");
438            return Err(ToriiError::Git(git2::Error::from_str(&format!(
439                "push rejected by remote: {}",
440                detail
441            ))));
442        }
443
444        // No callback at all = transport silently dropped the push. Caught
445        // in the wild pushing 3GB to GitLab over SSH where libgit2 returned
446        // Ok with zero refs ever acknowledged by the server.
447        let acks = acknowledged.lock().unwrap();
448        if acks.is_empty() {
449            return Err(ToriiError::Git(git2::Error::from_str(
450                "push completed without server acknowledging any refs — \
451                 transport may have failed silently. Check network / auth and retry. \
452                 (Common with very large pushes over SSH; try HTTPS with a token.)"
453            )));
454        }
455
456        // Push tags via git2 — enumerate local tags and push each one
457        self.push_all_tags("origin", force)?;
458
459        Ok(())
460    }
461
462    /// Push local tags to a remote, but **only the ones that aren't already
463    /// in sync** with what the remote advertises.
464    ///
465    /// 0.7.8: pre-fix this function pushed every local tag on every
466    /// `torii sync --push`. GitLab fires its `workflow:rules` on each tag
467    /// ref the remote sees in a push event — even when the tag OID is
468    /// identical to what was already there — so every release retriggered
469    /// CI pipelines for every historical tag (v0.7.0, v0.7.1, v0.7.2, …).
470    /// In the gitorii repo this was producing 4+ stale pipelines per
471    /// release that all eventually got canceled, plus wasted runner time
472    /// while they were queued.
473    ///
474    /// The fix: do an ls-remote (libgit2's `Remote::list` after
475    /// `connect_auth`), compare each local tag's OID against the remote's,
476    /// and push *only* the ones that differ (or don't exist remotely).
477    /// One extra network round-trip in exchange for not retriggering N
478    /// pipelines per release. With `force=true` the comparison still
479    /// holds but the refspec gets a `+` prefix so rewritten tag OIDs
480    /// (e.g. after `torii history reauthor --since ...`) still go through.
481    pub fn push_all_tags(&self, remote_name: &str, force: bool) -> Result<()> {
482        let local_tags = self.repo.tag_names(None)?;
483        if local_tags.is_empty() {
484            return Ok(());
485        }
486
487        // Build the local-OID map first so we don't keep the repo borrowed
488        // while we open the remote connection.
489        let local: std::collections::HashMap<String, git2::Oid> = local_tags.iter()
490            .flatten()
491            .filter_map(|t| {
492                let refname = format!("refs/tags/{}", t);
493                self.repo.refname_to_id(&refname).ok().map(|oid| (t.to_string(), oid))
494            })
495            .collect();
496
497        let mut remote = self.repo.find_remote(remote_name)?;
498        let remote_url = remote.url().unwrap_or("").to_string();
499
500        // ls-remote equivalent: connect, list, disconnect. We pass a fresh
501        // set of auth callbacks since `connect_auth` consumes them.
502        let remote_tags: std::collections::HashMap<String, git2::Oid> = {
503            let callbacks = Self::auth_callbacks_for(&remote_url);
504            remote.connect_auth(git2::Direction::Fetch, Some(callbacks), None)?;
505            let list = remote.list()?;
506            let map = list.iter()
507                .filter_map(|h| {
508                    let name = h.name();
509                    name.strip_prefix("refs/tags/")
510                        // Drop the peeled-tag suffix `^{}` libgit2 sometimes
511                        // surfaces in remote listings for annotated tags —
512                        // we only care about the tag object itself, which
513                        // is the entry without the suffix.
514                        .filter(|n| !n.ends_with("^{}"))
515                        .map(|n| (n.to_string(), h.oid()))
516                })
517                .collect::<std::collections::HashMap<_, _>>();
518            remote.disconnect()?;
519            map
520        };
521
522        // Only the tags whose OID differs (or are missing remotely) get a
523        // refspec. Tags that match are left alone — GitLab won't see a
524        // push event for them, so its workflow:rules won't fire.
525        let refspecs: Vec<String> = local.iter()
526            .filter(|(name, oid)| remote_tags.get(*name) != Some(oid))
527            .map(|(t, _)| {
528                let r = format!("refs/tags/{}:refs/tags/{}", t, t);
529                if force { format!("+{}", r) } else { r }
530            })
531            .collect();
532
533        if refspecs.is_empty() {
534            return Ok(());
535        }
536
537        let refspec_refs: Vec<&str> = refspecs.iter().map(|s| s.as_str()).collect();
538        let callbacks = Self::auth_callbacks_for(&remote_url);
539        let mut push_options = git2::PushOptions::new();
540        push_options.remote_callbacks(callbacks);
541        remote.push(&refspec_refs, Some(&mut push_options))
542            .map_err(ToriiError::Git)?;
543        Ok(())
544    }
545
546    /// Get current branch name
547    pub fn get_current_branch(&self) -> Result<String> {
548        let head = self.repo.head()?;
549        let branch_name = head.shorthand()
550            .ok_or_else(|| ToriiError::Git(git2::Error::from_str("Could not get branch name")))?;
551        Ok(branch_name.to_string())
552    }
553
554    /// Get the underlying libgit2 repository handle. Crate-internal on
555    /// purpose: the public API must not leak `git2` types (the future
556    /// `VcsEngine` trait depends on that boundary).
557    pub(crate) fn repository(&self) -> &Repository {
558        &self.repo
559    }
560
561    /// Absolute path of the work tree, when the repository isn't bare.
562    pub fn workdir(&self) -> Option<&Path> {
563        self.repo.workdir()
564    }
565
566    /// Remote aliases configured in the repo, in config order, with their
567    /// fetch URLs (`None` when a remote has no URL).
568    pub fn remotes(&self) -> Result<Vec<(String, Option<String>)>> {
569        let names = self.repo.remotes()?;
570        let mut out = Vec::new();
571        for name in names.iter().flatten() {
572            let url = self
573                .repo
574                .find_remote(name)
575                .ok()
576                .and_then(|r| r.url().map(String::from));
577            out.push((name.to_string(), url));
578        }
579        Ok(out)
580    }
581
582    /// Whether a remote alias with this name exists.
583    pub fn remote_exists(&self, name: &str) -> bool {
584        self.repo.find_remote(name).is_ok()
585    }
586
587    /// Fetch URL of a remote, when configured. Errors if the remote
588    /// doesn't exist.
589    pub fn remote_url(&self, name: &str) -> Result<Option<String>> {
590        let remote = self.repo.find_remote(name)?;
591        Ok(remote.url().map(String::from))
592    }
593
594    /// Register a new remote alias.
595    pub fn remote_add(&self, name: &str, url: &str) -> Result<()> {
596        self.repo.remote(name, url)?;
597        Ok(())
598    }
599
600    /// Overwrite the URL of an existing remote alias.
601    pub fn remote_set_url(&self, name: &str, url: &str) -> Result<()> {
602        self.repo.remote_set_url(name, url)?;
603        Ok(())
604    }
605
606    /// Drop a remote alias (does not touch the platform side).
607    pub fn remote_delete(&self, name: &str) -> Result<()> {
608        self.repo.remote_delete(name)?;
609        Ok(())
610    }
611
612    /// Collect repository status — branch, HEAD summary, remote tracking
613    /// and per-file changes. Pure data: rendering lives with the callers
614    /// (CLI, TUI, IDE).
615    pub fn status(&self) -> Result<RepoStatus> {
616        let mut opts = StatusOptions::new();
617        opts.include_untracked(true);
618        let statuses = self.repo.statuses(Some(&mut opts))?;
619
620        let branch = self.get_current_branch()?;
621
622        let head = self.repo.head().ok()
623            .and_then(|h| h.peel_to_commit().ok())
624            .map(|commit| HeadCommitInfo {
625                short_id: format!("{:.7}", commit.id()),
626                summary: commit.message().unwrap_or("").lines().next().unwrap_or("").to_string(),
627                seconds_since_epoch: commit.time().seconds(),
628            });
629
630        let remote = self.repo.find_remote("origin").ok().and_then(|remote| {
631            let url = remote.url()?;
632            let name = url.split('/').last().unwrap_or("origin")
633                .trim_end_matches(".git").to_string();
634            let ahead_behind = self.repo.head().ok()
635                .and_then(|h| h.target())
636                .and_then(|local_oid| {
637                    let remote_ref = self.repo
638                        .find_reference(&format!("refs/remotes/origin/{}", branch)).ok()?;
639                    let remote_oid = remote_ref.target()?;
640                    self.repo.graph_ahead_behind(local_oid, remote_oid).ok()
641                });
642            Some(RemoteStatusInfo { name, ahead_behind })
643        });
644
645        let mut staged = Vec::new();
646        let mut unstaged = Vec::new();
647        let mut untracked = Vec::new();
648
649        for entry in statuses.iter() {
650            let status = entry.status();
651            let path = entry.path().unwrap_or("unknown").to_string();
652
653            if status.is_index_new() || status.is_index_modified() || status.is_index_deleted() {
654                let kind = if status.is_index_new() {
655                    ChangeKind::Added
656                } else if status.is_index_modified() {
657                    ChangeKind::Modified
658                } else {
659                    ChangeKind::Deleted
660                };
661                staged.push(StatusEntry { kind, path: path.clone() });
662            }
663
664            if status.is_wt_modified() || status.is_wt_deleted() {
665                let kind = if status.is_wt_modified() {
666                    ChangeKind::Modified
667                } else {
668                    ChangeKind::Deleted
669                };
670                unstaged.push(StatusEntry { kind, path: path.clone() });
671            }
672
673            if status.is_wt_new() {
674                untracked.push(path);
675            }
676        }
677
678        Ok(RepoStatus { branch, head, remote, staged, unstaged, untracked })
679    }
680
681    /// Get git signature using the unified resolver.
682    fn get_signature(&self) -> Result<Signature<'static>> {
683        resolve_signature(&self.repo)
684    }
685}
686
687/// What kind of change a status entry represents.
688#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
689pub enum ChangeKind {
690    Added,
691    Modified,
692    Deleted,
693}
694
695/// A single staged or unstaged path in the status report.
696#[derive(Debug, Clone, serde::Serialize)]
697pub struct StatusEntry {
698    pub kind: ChangeKind,
699    pub path: String,
700}
701
702/// HEAD commit summary for the status report.
703#[derive(Debug, Clone, serde::Serialize)]
704pub struct HeadCommitInfo {
705    pub short_id: String,
706    /// First line of the commit message.
707    pub summary: String,
708    /// Commit time as seconds since the Unix epoch.
709    pub seconds_since_epoch: i64,
710}
711
712/// Tracking state against `origin/<branch>`.
713#[derive(Debug, Clone, serde::Serialize)]
714pub struct RemoteStatusInfo {
715    /// Remote repo name derived from the URL (without `.git`).
716    pub name: String,
717    /// `(ahead, behind)` vs the upstream branch, when it exists.
718    pub ahead_behind: Option<(usize, usize)>,
719}
720
721/// Repository state decoupled from presentation — built by
722/// [`GitRepo::status`], rendered by the CLI / TUI / IDE layers.
723#[derive(Debug, Clone, serde::Serialize)]
724pub struct RepoStatus {
725    pub branch: String,
726    pub head: Option<HeadCommitInfo>,
727    pub remote: Option<RemoteStatusInfo>,
728    pub staged: Vec<StatusEntry>,
729    pub unstaged: Vec<StatusEntry>,
730    pub untracked: Vec<String>,
731}
732
733impl RepoStatus {
734    pub fn is_clean(&self) -> bool {
735        self.staged.is_empty() && self.unstaged.is_empty() && self.untracked.is_empty()
736    }
737}
738
739/// Resolve a commit signature for the current user, in this order:
740///
741///   1. Torii **local** config (`.torii/config.toml [user]` under the
742///      repo's work tree) — set via `torii config set user.name X --local`.
743///      Per-repo identity wins so users can keep a personal global and a
744///      work-tree-specific override (e.g. `paski@paski.dev` globally,
745///      `paski@employer.com` for the work repo).
746///   2. Torii **global** config (`~/.config/torii/config.toml [user]`)
747///      — set via `torii config set user.name X` without `--local`.
748///   3. Git's own config chain (`.git/config` → `~/.gitconfig` →
749///      `/etc/gitconfig`). Kept so users who already had a working
750///      `git config` setup don't have to duplicate it.
751///   4. Hard error. **No more silent fallback to "Torii User"** — that
752///      placeholder was the root cause of an earlier author-fallback
753///      bug. Bogus commits are worse than a clear error that prompts
754///      the user to fix it.
755///
756/// Returns the signature ready to pass to `repo.commit(..)`.
757///
758/// Returns `Signature<'static>` deliberately: callers often need to
759/// hold the signature past a subsequent `&mut repo` operation
760/// (`stash_save2`, `commit` after another index op), and the
761/// `'static` lifetime decouples it from the borrow used here. Possible
762/// because `Signature::now` produces an owned signature.
763// Porcelain tier (see `commit_inner`): takes a raw `git2::Repository` for
764// the TUI's sake. Domain callers should go through `GitRepo` methods.
765#[doc(hidden)]
766pub fn resolve_signature(repo: &git2::Repository) -> Result<Signature<'static>> {
767    // `load_local(repo)` already merges global underneath local — the
768    // `--local` override naturally wins. Fall back to global-only if
769    // the repo is bare (no workdir to host a `.torii/config.toml`).
770    //
771    // Pre-0.7.14: only `load_global()` was consulted here, so `--local`
772    // edits to user.name/user.email were silently ignored. That's the
773    // bug fixed by switching to `load_local()`.
774    let tc = repo.workdir()
775        .and_then(|wd| crate::config::ToriiConfig::load_local(wd).ok())
776        .unwrap_or_else(|| crate::config::ToriiConfig::load_global().unwrap_or_default());
777
778    // Filter out empty strings at every level. `torii config set user.name ""`
779    // counts as "not configured" — the previous behaviour passed the empty
780    // string straight to libgit2, which then rejected the commit with a
781    // generic "Signature cannot have an empty name or email" instead of our
782    // torii-flavoured fix-it hint.
783    let name = tc
784        .user
785        .name
786        .clone()
787        .filter(|s| !s.trim().is_empty())
788        .or_else(|| {
789            repo.config()
790                .ok()
791                .and_then(|c| c.get_string("user.name").ok())
792                .filter(|s| !s.trim().is_empty())
793        })
794        .ok_or_else(|| {
795            crate::error::ToriiError::InvalidConfig(
796                "user.name not configured. Set it with:\n  \
797                 torii config set user.name \"Your Name\""
798                    .to_string(),
799            )
800        })?;
801
802    let email = tc
803        .user
804        .email
805        .clone()
806        .filter(|s| !s.trim().is_empty())
807        .or_else(|| {
808            repo.config()
809                .ok()
810                .and_then(|c| c.get_string("user.email").ok())
811                .filter(|s| !s.trim().is_empty())
812        })
813        .ok_or_else(|| {
814            crate::error::ToriiError::InvalidConfig(
815                "user.email not configured. Set it with:\n  \
816                 torii config set user.email \"you@example.com\""
817                    .to_string(),
818            )
819        })?;
820
821    Ok(Signature::now(&name, &email)?)
822}
823
824/// Create a commit, signing it with GPG when the active torii config
825/// has `git.sign_commits = true`. Returns the new commit's OID.
826///
827/// When signing is off this is a thin wrapper around
828/// [`git2::Repository::commit`] — same behaviour, same ref update.
829/// When signing is on it uses libgit2's `commit_create_buffer` +
830/// `commit_signed` pair and manually updates the named ref (libgit2
831/// doesn't do ref-bookkeeping for signed commits, see
832/// <https://libgit2.org/libgit2/#HEAD/group/commit/git_commit_signed>).
833///
834/// This is the **fix for the GPG-sign no-op bug** in 0.7.13 and
835/// earlier: those versions accepted `git.sign_commits = true` in the
836/// config layer but never honoured it at commit time. From 0.7.14
837/// onwards the flag actually drives this branch.
838// Porcelain tier: still consumed by the TUI in the `gitorii` crate, which
839// predates the lib/bin split and works on raw `git2` handles. Hidden from
840// the documented domain API — slated for migration behind `GitRepo`.
841#[doc(hidden)]
842pub fn commit_inner(
843    repo: &git2::Repository,
844    update_ref: Option<&str>,
845    sig: &Signature,
846    message: &str,
847    tree: &git2::Tree,
848    parents: &[&git2::Commit],
849) -> Result<git2::Oid> {
850    // Convenience wrapper for the common case where author == committer.
851    commit_inner_split(repo, update_ref, sig, sig, message, tree, parents)
852}
853
854/// Like [`commit_inner`] but takes the author and committer separately.
855/// Used by history-rewriting ops (reauthor, rewrite, remove-file) that
856/// preserve the original author while changing the committer.
857pub(crate) fn commit_inner_split(
858    repo: &git2::Repository,
859    update_ref: Option<&str>,
860    author: &Signature,
861    committer: &Signature,
862    message: &str,
863    tree: &git2::Tree,
864    parents: &[&git2::Commit],
865) -> Result<git2::Oid> {
866    // Cheap config read — load_local already merges global underneath
867    // local, so a per-repo override of `git.sign_commits` works as
868    // expected.
869    let tc = repo.workdir()
870        .and_then(|wd| crate::config::ToriiConfig::load_local(wd).ok())
871        .unwrap_or_else(|| crate::config::ToriiConfig::load_global().unwrap_or_default());
872
873    // 0.7.35 — per-invocation override set by `torii save -S` /
874    // `--no-sign`. The CLI handler sets this env var around the
875    // commit call so we don't have to thread a `force_sign: Option<bool>`
876    // through every commit path (initial commit, amend, history
877    // rewrite, the TUI's save action, …). Returned to its prior
878    // state by the CLI guard after the commit.
879    let should_sign = match std::env::var("TORII_SIGN_OVERRIDE").ok().as_deref() {
880        Some("true")  => true,
881        Some("false") => false,
882        _             => tc.git.sign_commits,
883    };
884
885    if !should_sign {
886        return Ok(repo.commit(update_ref, author, committer, message, tree, parents)?);
887    }
888
889    // Signing path: need a key id.
890    let key = tc.git.gpg_key.as_deref()
891        .filter(|s| !s.trim().is_empty())
892        .ok_or_else(|| ToriiError::InvalidConfig(
893            "git.sign_commits = true but git.gpg_key is not set. Configure with:\n  \
894             torii config set git.gpg_key <YOUR-KEY-ID>".to_string()
895        ))?;
896
897    // Build the commit object as bytes, sign it, then write the signed
898    // commit object out.
899    let buffer = repo.commit_create_buffer(author, committer, message, tree, parents)?;
900    let buffer_str = std::str::from_utf8(&buffer)
901        .map_err(|e| ToriiError::RepoState(format!(
902            "commit buffer not valid UTF-8 (cannot GPG-sign): {}", e
903        )))?;
904    // 0.7.35 — honour `git.gpg_program` so users on systems where gpg
905    // is shipped as gpg2 (or under a custom path) can point at it.
906    let signature = crate::gpg::sign_blob(
907        &buffer,
908        key,
909        tc.git.gpg_program.as_deref(),
910    )?;
911    let new_oid = repo.commit_signed(buffer_str, &signature, Some("gpgsig"))?;
912
913    // libgit2's commit_signed leaves ref updates to the caller. Move
914    // the requested ref (typically HEAD, which resolves through the
915    // symbolic chain to the current branch).
916    if let Some(name) = update_ref {
917        // Resolve "HEAD" to the underlying branch ref so we update the
918        // branch, not the symbolic HEAD itself. For non-HEAD names we
919        // assume the caller passed a direct ref path.
920        let target_ref = if name == "HEAD" {
921            match repo.head() {
922                Ok(h) => h.name().map(String::from).unwrap_or_else(|| "refs/heads/main".to_string()),
923                // Unborn HEAD: first commit. Use the default branch
924                // name from torii config so this matches `torii init`.
925                Err(_) => format!("refs/heads/{}", tc.git.default_branch),
926            }
927        } else {
928            name.to_string()
929        };
930        repo.reference(&target_ref, new_oid, true, "torii signed commit")?;
931    }
932
933    Ok(new_oid)
934}
935
936#[cfg(test)]
937mod add_all_tests {
938    use super::*;
939    use std::fs;
940    use tempfile::TempDir;
941
942    #[test]
943    fn add_all_skips_dot_torii_directory() {
944        let tmp = TempDir::new().unwrap();
945        let repo_path = tmp.path();
946        // Bare-minimum init: a regular non-bare repo with no initial
947        // commit (add_all only writes the index, doesn't require HEAD).
948        let _ = git2::Repository::init(repo_path).unwrap();
949        let gitorii = GitRepo::open(repo_path).unwrap();
950
951        // Real change that SHOULD be staged.
952        fs::write(repo_path.join("README.md"), "hello").unwrap();
953        // Bogus .torii/ content that must NEVER be staged by -a.
954        fs::create_dir_all(repo_path.join(".torii/snapshots/x")).unwrap();
955        fs::write(repo_path.join(".torii/snapshots/x/big.bin"), vec![0u8; 1024]).unwrap();
956        fs::write(repo_path.join(".torii/config.json"), "{}").unwrap();
957
958        gitorii.add_all().unwrap();
959
960        let index = gitorii.repo.index().unwrap();
961        let staged: Vec<String> = index.iter()
962            .map(|e| String::from_utf8_lossy(&e.path).into_owned())
963            .collect();
964
965        assert!(staged.iter().any(|p| p == "README.md"),
966                "README.md should be staged, got: {:?}", staged);
967        assert!(!staged.iter().any(|p| p.starts_with(".torii")),
968                ".torii/* must not be staged by add_all, got: {:?}", staged);
969    }
970}