1use git2::BranchType;
3use crate::error::Result;
4use crate::core::GitRepo;
5use chrono::NaiveDateTime;
6
7impl GitRepo {
8 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 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 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 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 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 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 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 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 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 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 #[cfg(unix)]
230 pub fn rebase_with_todo(&self, base: &str, todo_file: &std::path::Path) -> Result<()> {
231 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 #[cfg(unix)]
261 pub fn rebase_interactive(&self, base: &str) -> Result<()> {
262 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 #[cfg(unix)]
283 pub fn rebase_root_interactive(&self) -> Result<()> {
284 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 #[cfg(unix)]
305 pub fn rebase_root_with_todo(&self, todo_file: &std::path::Path) -> Result<()> {
306 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 pub fn rebase_continue(&self) -> Result<()> {
336 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 rebase.commit(None, &sig, None)
350 .map_err(|e| crate::error::ToriiError::Git(e))?;
351
352 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 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 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 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 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 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 fn delegate_rebase_subcommand(&self, flag: &str, verb: &str) -> Result<()> {
420 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 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 pub fn diff(&self, staged: bool, last: bool) -> Result<()> {
442 if last {
443 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 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 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 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 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 if !name.ends_with("/HEAD") {
515 branch_names.push(name.to_string());
516 }
517 }
518 }
519
520 Ok(branch_names)
521 }
522
523 pub fn create_orphan_branch(&self, name: &str) -> Result<()> {
528 let refname = format!("refs/heads/{}", name);
529 if self.repository().find_reference(&refname).is_ok() {
531 return Err(crate::error::ToriiError::Usage(
532 format!("Branch '{}' already exists", name)
533 ));
534 }
535 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 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 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 pub fn checkout_remote_branch(&self, remote_name: &str) -> Result<()> {
567 let local_name = remote_name
569 .splitn(2, '/')
570 .nth(1)
571 .unwrap_or(remote_name);
572 let repo = self.repository();
573 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 branch.set_upstream(Some(remote_name))?;
580 }
581 self.switch_branch(local_name)
582 }
583
584 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 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 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 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 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 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 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 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 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 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 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 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 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 pub fn clean_history(&self) -> Result<()> {
854 println!("π§Ή Cleaning repository...");
855
856 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 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 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 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 pub fn fetch(&self) -> Result<()> {
939 println!("π Fetching from remote...");
940 self.fetch_one("origin")?;
941 Ok(())
942 }
943
944 pub fn fetch_named(&self, name: &str) -> Result<()> {
947 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 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 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 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 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 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 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 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 pub fn rebase_branch(&self, branch_name: &str) -> Result<()> {
1138 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 #[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 pub fn show(&self, object: Option<&str>) -> Result<()> {
1203 let target = object.unwrap_or("HEAD");
1205
1206 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 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#[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 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#[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 out_lines.push(format!("reword {}", sha));
1405 } else {
1406 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#[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 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, };
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
1521fn 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
1555fn 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 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}