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 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 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 git_repo.sync_toriignore()?;
34 Ok(git_repo)
35 }
36
37 pub fn sync_toriignore(&self) -> Result<()> {
42 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 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 } else {
99 0 }
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 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 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 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 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 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 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 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 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 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 pub(crate) fn auth_callbacks_for<'a>(url: &str) -> git2::RemoteCallbacks<'a> {
211 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 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 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 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 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 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 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 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 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 Self::attach_push_progress(&mut callbacks);
423
424 let mut push_options = git2::PushOptions::new();
425 push_options.remote_callbacks(callbacks);
426
427 remote.push(&[&refspec], Some(&mut push_options))?;
429
430 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 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 self.push_all_tags("origin", force)?;
458
459 Ok(())
460 }
461
462 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 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 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 .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 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 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 pub(crate) fn repository(&self) -> &Repository {
558 &self.repo
559 }
560
561 pub fn workdir(&self) -> Option<&Path> {
563 self.repo.workdir()
564 }
565
566 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 pub fn remote_exists(&self, name: &str) -> bool {
584 self.repo.find_remote(name).is_ok()
585 }
586
587 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 pub fn remote_add(&self, name: &str, url: &str) -> Result<()> {
596 self.repo.remote(name, url)?;
597 Ok(())
598 }
599
600 pub fn remote_set_url(&self, name: &str, url: &str) -> Result<()> {
602 self.repo.remote_set_url(name, url)?;
603 Ok(())
604 }
605
606 pub fn remote_delete(&self, name: &str) -> Result<()> {
608 self.repo.remote_delete(name)?;
609 Ok(())
610 }
611
612 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 fn get_signature(&self) -> Result<Signature<'static>> {
683 resolve_signature(&self.repo)
684 }
685}
686
687#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
689pub enum ChangeKind {
690 Added,
691 Modified,
692 Deleted,
693}
694
695#[derive(Debug, Clone, serde::Serialize)]
697pub struct StatusEntry {
698 pub kind: ChangeKind,
699 pub path: String,
700}
701
702#[derive(Debug, Clone, serde::Serialize)]
704pub struct HeadCommitInfo {
705 pub short_id: String,
706 pub summary: String,
708 pub seconds_since_epoch: i64,
710}
711
712#[derive(Debug, Clone, serde::Serialize)]
714pub struct RemoteStatusInfo {
715 pub name: String,
717 pub ahead_behind: Option<(usize, usize)>,
719}
720
721#[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#[doc(hidden)]
766pub fn resolve_signature(repo: &git2::Repository) -> Result<Signature<'static>> {
767 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 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#[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 commit_inner_split(repo, update_ref, sig, sig, message, tree, parents)
852}
853
854pub(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 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 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 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 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 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 if let Some(name) = update_ref {
917 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 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 let _ = git2::Repository::init(repo_path).unwrap();
949 let gitorii = GitRepo::open(repo_path).unwrap();
950
951 fs::write(repo_path.join("README.md"), "hello").unwrap();
953 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}