1use std::path::{Path, PathBuf};
7use std::process::Stdio;
8
9use crate::proc::Quiet as _;
10use anyhow::{Context as _, Result, bail};
11use tokio::process::Command;
12
13#[derive(Debug)]
15pub struct GitOut {
16 pub code: Option<i32>,
18 pub stdout: String,
20 pub stderr: String,
22}
23
24impl GitOut {
25 pub fn ok(&self) -> bool {
27 self.code == Some(0)
28 }
29}
30
31pub async fn git_raw(cwd: &Path, args: &[&str]) -> Result<GitOut> {
34 let out = Command::new("git")
35 .args(args)
36 .current_dir(cwd)
37 .quiet()
38 .env("GIT_TERMINAL_PROMPT", "0")
41 .env("GIT_EDITOR", "true")
42 .stdin(Stdio::null())
43 .output()
44 .await
45 .with_context(|| format!("spawn git {}", args.join(" ")))?;
46 Ok(GitOut {
47 code: out.status.code(),
48 stdout: String::from_utf8_lossy(&out.stdout).trim_end().to_owned(),
49 stderr: String::from_utf8_lossy(&out.stderr).trim_end().to_owned(),
50 })
51}
52
53pub async fn git(cwd: &Path, args: &[&str]) -> Result<String> {
55 let out = git_raw(cwd, args).await?;
56 if !out.ok() {
57 bail!(
58 "git {} failed in {} (exit {:?}): {}",
59 args.join(" "),
60 cwd.display(),
61 out.code,
62 if out.stderr.is_empty() {
63 out.stdout.as_str()
64 } else {
65 out.stderr.as_str()
66 }
67 );
68 }
69 Ok(out.stdout)
70}
71
72pub async fn toplevel(path: &Path) -> Result<PathBuf> {
74 let out = git(path, &["rev-parse", "--show-toplevel"]).await?;
75 Ok(PathBuf::from(out))
76}
77
78pub async fn rev_parse(repo: &Path, rev: &str) -> Result<String> {
80 git(repo, &["rev-parse", rev]).await
81}
82
83pub async fn current_branch(repo: &Path) -> Result<Option<String>> {
85 let out = git_raw(repo, &["symbolic-ref", "--quiet", "--short", "HEAD"]).await?;
86 Ok(if out.ok() && !out.stdout.is_empty() {
87 Some(out.stdout)
88 } else {
89 None
90 })
91}
92
93pub async fn is_clean(repo: &Path) -> Result<bool> {
95 Ok(git(repo, &["status", "--porcelain"]).await?.is_empty())
96}
97
98pub async fn status_porcelain(repo: &Path) -> Result<String> {
100 git(repo, &["status", "--porcelain"]).await
101}
102
103pub async fn worktree_add_branch(repo: &Path, path: &Path, branch: &str, base: &str) -> Result<()> {
105 if let Some(parent) = path.parent() {
106 tokio::fs::create_dir_all(parent).await.ok();
107 }
108 let path_s = path.to_string_lossy().to_string();
109 git(repo, &["worktree", "add", "-b", branch, &path_s, base])
110 .await
111 .map(|_| ())
112}
113
114pub async fn worktree_add_detached(repo: &Path, path: &Path, rev: &str) -> Result<()> {
116 if let Some(parent) = path.parent() {
117 tokio::fs::create_dir_all(parent).await.ok();
118 }
119 let path_s = path.to_string_lossy().to_string();
120 git(repo, &["worktree", "add", "--detach", &path_s, rev])
121 .await
122 .map(|_| ())
123}
124
125pub async fn reset_detached(worktree: &Path, rev: &str) -> Result<()> {
127 git(worktree, &["checkout", "--detach", rev]).await?;
128 git(worktree, &["reset", "--hard", rev]).await?;
129 git(worktree, &["clean", "-fdx"]).await?;
130 Ok(())
131}
132
133pub async fn worktree_remove(repo: &Path, path: &Path) -> Result<bool> {
136 let path_s = path.to_string_lossy().to_string();
137 let out = git_raw(repo, &["worktree", "remove", "--force", &path_s]).await?;
138 if out.ok() {
139 return Ok(true);
140 }
141 git_raw(repo, &["worktree", "prune"]).await?;
143 Ok(false)
144}
145
146pub async fn remove_worktree_from_linked(dir: &Path) {
155 let Ok(link) = std::fs::read_to_string(dir.join(".git")) else {
156 return;
157 };
158 let Some(admin) = link.strip_prefix("gitdir:").map(str::trim) else {
159 return;
160 };
161 let admin = Path::new(admin);
164 let Some(common) = admin.parent().and_then(Path::parent) else {
165 return;
166 };
167 let common_s = common.to_string_lossy();
168 let _ = git_raw(dir, &["--git-dir", &common_s, "worktree", "prune"]).await;
169}
170
171pub async fn branch_delete(repo: &Path, branch: &str) -> Result<bool> {
173 Ok(git_raw(repo, &["branch", "-D", branch]).await?.ok())
174}
175
176pub async fn branch_exists(repo: &Path, branch: &str) -> Result<bool> {
178 let refname = format!("refs/heads/{branch}");
179 Ok(
180 git_raw(repo, &["show-ref", "--verify", "--quiet", &refname])
181 .await?
182 .ok(),
183 )
184}
185
186pub async fn diff(worktree: &Path, base: &str, head: &str) -> Result<String> {
188 let range = format!("{base}...{head}");
189 git(
190 worktree,
191 &["diff", "--no-color", "--no-ext-diff", "-M", &range],
192 )
193 .await
194}
195
196pub async fn diff_stat(worktree: &Path, base: &str, head: &str) -> Result<String> {
198 let range = format!("{base}...{head}");
199 git(worktree, &["diff", "--no-color", "--stat", &range]).await
200}
201
202pub async fn changed_files(worktree: &Path, base: &str, head: &str) -> Result<Vec<String>> {
204 let range = format!("{base}...{head}");
205 let out = git(worktree, &["diff", "--name-only", &range]).await?;
206 Ok(out.lines().map(str::to_owned).collect())
207}
208
209pub async fn log_oneline(worktree: &Path, base: &str, head: &str) -> Result<String> {
211 let range = format!("{base}..{head}");
212 git(
213 worktree,
214 &["log", "--reverse", "--format=%s%n%b%n--", &range],
215 )
216 .await
217}
218
219pub async fn commits_ahead(worktree: &Path, base: &str, head: &str) -> Result<usize> {
221 let range = format!("{base}..{head}");
222 let out = git(worktree, &["rev-list", "--count", &range]).await?;
223 Ok(out.trim().parse().unwrap_or(0))
224}
225
226pub async fn commit_all(worktree: &Path, message: &str) -> Result<bool> {
233 if git(worktree, &["status", "--porcelain"]).await?.is_empty() {
234 return Ok(false);
235 }
236 git(worktree, &["add", "-A"]).await?;
237 let out = git_raw(
238 worktree,
239 &[
240 "-c",
241 "user.name=magi candidate",
242 "-c",
243 "user.email=magi@localhost",
244 "commit",
245 "--no-verify",
246 "-m",
247 message,
248 ],
249 )
250 .await?;
251 if !out.ok() {
252 bail!("rescue commit failed: {}", out.stderr);
253 }
254 Ok(true)
255}
256
257pub async fn enable_worktree_config(repo: &Path) -> Result<bool> {
262 let out = git_raw(repo, &["config", "--get", "extensions.worktreeConfig"]).await?;
263 if out.ok() && out.stdout.trim() == "true" {
264 return Ok(false);
265 }
266 git(repo, &["config", "extensions.worktreeConfig", "true"]).await?;
267 Ok(true)
268}
269
270pub async fn disable_worktree_config(repo: &Path) -> Result<()> {
272 git_raw(repo, &["config", "--unset", "extensions.worktreeConfig"]).await?;
273 Ok(())
274}
275
276pub async fn set_worktree_hooks_path(worktree: &Path, hooks_dir: &Path) -> Result<()> {
282 let dir = hooks_dir.to_string_lossy().replace('\\', "/");
283 git(worktree, &["config", "--worktree", "core.hooksPath", &dir])
284 .await
285 .map(|_| ())
286}
287
288pub async fn local_exclude(worktree: &Path, pattern: &str) -> Result<()> {
290 let git_dir = git(worktree, &["rev-parse", "--git-path", "info/exclude"]).await?;
291 let path = worktree.join(git_dir);
292 if let Some(parent) = path.parent() {
293 tokio::fs::create_dir_all(parent).await.ok();
294 }
295 let mut body = tokio::fs::read_to_string(&path).await.unwrap_or_default();
296 if body.lines().any(|l| l.trim() == pattern) {
297 return Ok(());
298 }
299 if !body.is_empty() && !body.ends_with('\n') {
300 body.push('\n');
301 }
302 body.push_str(pattern);
303 body.push('\n');
304 tokio::fs::write(&path, body)
305 .await
306 .with_context(|| format!("write {}", path.display()))?;
307 Ok(())
308}
309
310pub async fn merge_no_ff(repo: &Path, branch: &str, message: &str) -> Result<GitOut> {
312 git_raw(
313 repo,
314 &["merge", "--no-ff", "--no-edit", "-m", message, branch],
315 )
316 .await
317}
318
319pub async fn push(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
321 git_raw(repo, &["push", "-u", remote, branch]).await
322}
323
324pub async fn push_rewritten(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
332 git_raw(repo, &["push", "--force-with-lease", remote, branch]).await
333}
334
335pub async fn rebase_branch_in_temp(
347 repo: &Path,
348 scratch: &Path,
349 branch: &str,
350 onto: &str,
351) -> Result<Option<String>> {
352 worktree_remove(repo, scratch).await.ok();
355 git_raw(
356 repo,
357 &[
358 "worktree",
359 "add",
360 "--force",
361 &scratch.to_string_lossy(),
362 branch,
363 ],
364 )
365 .await?;
366
367 let out = git_raw(scratch, &["rebase", onto]).await?;
368 if out.ok() {
369 worktree_remove(repo, scratch).await.ok();
370 return Ok(None);
371 }
372 git_raw(scratch, &["rebase", "--abort"]).await.ok();
374 let why = if out.stderr.trim().is_empty() {
375 out.stdout.trim().to_owned()
376 } else {
377 out.stderr.trim().to_owned()
378 };
379 worktree_remove(repo, scratch).await.ok();
380 Ok(Some(why))
381}
382
383pub async fn sync_to_head(worktree: &Path) -> Result<()> {
396 git(worktree, &["reset", "--hard", "HEAD"]).await?;
397 git(worktree, &["clean", "-fdx"]).await?;
398 Ok(())
399}
400
401pub async fn fetch(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
420 let refspec = format!("+refs/heads/{branch}:refs/remotes/{remote}/{branch}");
421 git_raw(repo, &["fetch", "--quiet", remote, &refspec]).await
422}
423
424pub async fn rev_exists(repo: &Path, rev: &str) -> bool {
426 git_raw(repo, &["rev-parse", "--verify", "--quiet", rev])
427 .await
428 .is_ok_and(|o| o.ok())
429}
430
431#[cfg(test)]
432mod tests {
433 use super::*;
434
435 async fn scratch() -> (tempfile::TempDir, PathBuf) {
436 let dir = tempfile::tempdir().unwrap();
437 let repo = dir.path().join("repo");
438 tokio::fs::create_dir_all(&repo).await.unwrap();
439 git(&repo, &["init", "-b", "main"]).await.unwrap();
440 git(&repo, &["config", "user.name", "test"]).await.unwrap();
441 git(&repo, &["config", "user.email", "test@example.com"])
442 .await
443 .unwrap();
444 tokio::fs::write(repo.join("a.txt"), "one\n").await.unwrap();
445 git(&repo, &["add", "-A"]).await.unwrap();
446 git(&repo, &["commit", "-m", "init"]).await.unwrap();
447 (dir, repo)
448 }
449
450 #[tokio::test]
451 async fn a_branch_rebases_onto_a_moved_base_and_says_when_it_cannot() {
452 let (_g, repo) = scratch().await;
453
454 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
456 tokio::fs::write(repo.join("b.txt"), "side\n")
457 .await
458 .unwrap();
459 git(&repo, &["add", "-A"]).await.unwrap();
460 git(&repo, &["commit", "-m", "side work"]).await.unwrap();
461
462 git(&repo, &["checkout", "main"]).await.unwrap();
465 tokio::fs::write(repo.join("c.txt"), "main\n")
466 .await
467 .unwrap();
468 git(&repo, &["add", "-A"]).await.unwrap();
469 git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
470
471 let scratch_tree = repo.parent().unwrap().join("rebase-scratch");
472 let clean = rebase_branch_in_temp(&repo, &scratch_tree, "side", "main")
473 .await
474 .unwrap();
475 assert!(clean.is_none(), "a disjoint change rebases: {clean:?}");
476 assert_eq!(
477 commits_ahead(&repo, "main", "side").await.unwrap(),
478 1,
479 "one commit, replayed onto the new base"
480 );
481 assert!(
482 !scratch_tree.exists(),
483 "the throwaway worktree is not left behind"
484 );
485
486 git(&repo, &["checkout", "-b", "clash"]).await.unwrap();
488 tokio::fs::write(repo.join("a.txt"), "clash\n")
489 .await
490 .unwrap();
491 git(&repo, &["add", "-A"]).await.unwrap();
492 git(&repo, &["commit", "-m", "clash"]).await.unwrap();
493 git(&repo, &["checkout", "main"]).await.unwrap();
494 tokio::fs::write(repo.join("a.txt"), "main edit\n")
495 .await
496 .unwrap();
497 git(&repo, &["add", "-A"]).await.unwrap();
498 git(&repo, &["commit", "-m", "main edit"]).await.unwrap();
499
500 let before = rev_parse(&repo, "clash").await.unwrap();
501 let why = rebase_branch_in_temp(&repo, &scratch_tree, "clash", "main")
502 .await
503 .unwrap()
504 .expect("a same-line clash cannot be rebased silently");
505 assert!(
506 why.to_lowercase().contains("conflict"),
507 "the reason is what git said, which is what a person needs: {why}"
508 );
509 assert_eq!(
510 rev_parse(&repo, "clash").await.unwrap(),
511 before,
512 "a failed rebase leaves the branch exactly where it was"
513 );
514 assert!(!scratch_tree.exists(), "and cleans up after itself");
515 }
516
517 #[tokio::test]
518 async fn a_sibling_worktree_stays_stale_after_a_rebase_until_synced() {
519 let (guard, repo) = scratch().await;
520
521 git(&repo, &["branch", "side"]).await.unwrap();
525 let side_wt = guard.path().join("side-wt");
526 git(
527 &repo,
528 &["worktree", "add", &side_wt.to_string_lossy(), "side"],
529 )
530 .await
531 .unwrap();
532 tokio::fs::write(side_wt.join("b.txt"), "candidate\n")
533 .await
534 .unwrap();
535 git(&side_wt, &["add", "-A"]).await.unwrap();
536 git(&side_wt, &["commit", "-m", "side work"]).await.unwrap();
537
538 git(&repo, &["checkout", "main"]).await.unwrap();
540 tokio::fs::write(repo.join("c.txt"), "main\n")
541 .await
542 .unwrap();
543 git(&repo, &["add", "-A"]).await.unwrap();
544 git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
545
546 let scratch_tree = guard.path().join("rebase-scratch");
548 let clean = rebase_branch_in_temp(&repo, &scratch_tree, "side", "main")
549 .await
550 .unwrap();
551 assert!(clean.is_none());
552
553 assert_eq!(
557 rev_parse(&side_wt, "HEAD").await.unwrap(),
558 rev_parse(&repo, "side").await.unwrap(),
559 "HEAD follows the moved ref"
560 );
561 assert!(
562 !side_wt.join("c.txt").exists(),
563 "stale until synced: main's new file has not reached this worktree's disk"
564 );
565
566 sync_to_head(&side_wt).await.unwrap();
567 assert!(side_wt.join("c.txt").is_file(), "synced now");
568 assert!(
569 side_wt.join("b.txt").is_file(),
570 "the worktree's own committed work survives the sync"
571 );
572 assert!(is_clean(&side_wt).await.unwrap());
573 }
574
575 #[tokio::test]
576 async fn clean_repo_reports_clean_then_dirty() {
577 let (_g, repo) = scratch().await;
578 assert!(is_clean(&repo).await.unwrap());
579 tokio::fs::write(repo.join("a.txt"), "two\n").await.unwrap();
580 assert!(!is_clean(&repo).await.unwrap());
581 }
582
583 #[tokio::test]
584 async fn worktree_lifecycle_and_diff() {
585 let (guard, repo) = scratch().await;
586 let base = rev_parse(&repo, "HEAD").await.unwrap();
587 let wt = guard.path().join("wt-a");
588 worktree_add_branch(&repo, &wt, "magi/test/a", &base)
589 .await
590 .unwrap();
591 tokio::fs::write(wt.join("b.txt"), "candidate\n")
592 .await
593 .unwrap();
594
595 assert!(commit_all(&wt, "candidate work").await.unwrap());
596 assert!(!commit_all(&wt, "nothing left").await.unwrap());
597
598 assert_eq!(commits_ahead(&wt, &base, "HEAD").await.unwrap(), 1);
599 let patch = diff(&wt, &base, "HEAD").await.unwrap();
600 assert!(patch.contains("b.txt"), "patch was: {patch}");
601 assert_eq!(
602 changed_files(&wt, &base, "HEAD").await.unwrap(),
603 ["b.txt".to_owned()]
604 );
605
606 let author = git(&wt, &["log", "-1", "--format=%an <%ae>"])
608 .await
609 .unwrap();
610 assert_eq!(author, "magi candidate <magi@localhost>");
611
612 assert!(worktree_remove(&repo, &wt).await.unwrap());
613 assert!(branch_exists(&repo, "magi/test/a").await.unwrap());
614 assert!(branch_delete(&repo, "magi/test/a").await.unwrap());
615 assert!(!branch_exists(&repo, "magi/test/a").await.unwrap());
616 }
617
618 #[tokio::test]
619 async fn worktree_scoped_hooks_path_does_not_leak_to_primary() {
620 let (guard, repo) = scratch().await;
621 let base = rev_parse(&repo, "HEAD").await.unwrap();
622 let wt = guard.path().join("wt-h");
623 worktree_add_branch(&repo, &wt, "magi/test/h", &base)
624 .await
625 .unwrap();
626 let hooks = guard.path().join("hooks");
627 tokio::fs::create_dir_all(&hooks).await.unwrap();
628
629 assert!(enable_worktree_config(&repo).await.unwrap());
630 set_worktree_hooks_path(&wt, &hooks).await.unwrap();
631
632 let in_wt = git(&wt, &["config", "--get", "core.hooksPath"])
633 .await
634 .unwrap();
635 assert!(!in_wt.is_empty());
636 let in_primary = git_raw(&repo, &["config", "--get", "core.hooksPath"])
637 .await
638 .unwrap();
639 assert!(
640 !in_primary.ok(),
641 "primary worktree must keep its own hooks: {in_primary:?}"
642 );
643
644 disable_worktree_config(&repo).await.unwrap();
645 }
646
647 #[tokio::test]
648 async fn local_exclude_is_idempotent() {
649 let (_g, repo) = scratch().await;
650 local_exclude(&repo, "/.magi/").await.unwrap();
651 local_exclude(&repo, "/.magi/").await.unwrap();
652 let path = repo.join(".git/info/exclude");
653 let body = tokio::fs::read_to_string(&path).await.unwrap();
654 assert_eq!(body.matches("/.magi/").count(), 1);
655 }
656}