1use std::path::{Path, PathBuf};
7use std::process::Stdio;
8
9use anyhow::{Context as _, Result, bail};
10use tokio::process::Command;
11
12#[derive(Debug)]
14pub struct GitOut {
15 pub code: Option<i32>,
17 pub stdout: String,
19 pub stderr: String,
21}
22
23impl GitOut {
24 pub fn ok(&self) -> bool {
26 self.code == Some(0)
27 }
28}
29
30pub async fn git_raw(cwd: &Path, args: &[&str]) -> Result<GitOut> {
33 let out = Command::new("git")
34 .args(args)
35 .current_dir(cwd)
36 .env("GIT_TERMINAL_PROMPT", "0")
39 .env("GIT_EDITOR", "true")
40 .stdin(Stdio::null())
41 .output()
42 .await
43 .with_context(|| format!("spawn git {}", args.join(" ")))?;
44 Ok(GitOut {
45 code: out.status.code(),
46 stdout: String::from_utf8_lossy(&out.stdout).trim_end().to_owned(),
47 stderr: String::from_utf8_lossy(&out.stderr).trim_end().to_owned(),
48 })
49}
50
51pub async fn git(cwd: &Path, args: &[&str]) -> Result<String> {
53 let out = git_raw(cwd, args).await?;
54 if !out.ok() {
55 bail!(
56 "git {} failed in {} (exit {:?}): {}",
57 args.join(" "),
58 cwd.display(),
59 out.code,
60 if out.stderr.is_empty() {
61 out.stdout.as_str()
62 } else {
63 out.stderr.as_str()
64 }
65 );
66 }
67 Ok(out.stdout)
68}
69
70pub async fn toplevel(path: &Path) -> Result<PathBuf> {
72 let out = git(path, &["rev-parse", "--show-toplevel"]).await?;
73 Ok(PathBuf::from(out))
74}
75
76pub async fn rev_parse(repo: &Path, rev: &str) -> Result<String> {
78 git(repo, &["rev-parse", rev]).await
79}
80
81pub async fn current_branch(repo: &Path) -> Result<Option<String>> {
83 let out = git_raw(repo, &["symbolic-ref", "--quiet", "--short", "HEAD"]).await?;
84 Ok(if out.ok() && !out.stdout.is_empty() {
85 Some(out.stdout)
86 } else {
87 None
88 })
89}
90
91pub async fn is_clean(repo: &Path) -> Result<bool> {
93 Ok(git(repo, &["status", "--porcelain"]).await?.is_empty())
94}
95
96pub async fn status_porcelain(repo: &Path) -> Result<String> {
98 git(repo, &["status", "--porcelain"]).await
99}
100
101pub async fn worktree_add_branch(repo: &Path, path: &Path, branch: &str, base: &str) -> Result<()> {
103 if let Some(parent) = path.parent() {
104 tokio::fs::create_dir_all(parent).await.ok();
105 }
106 let path_s = path.to_string_lossy().to_string();
107 git(repo, &["worktree", "add", "-b", branch, &path_s, base])
108 .await
109 .map(|_| ())
110}
111
112pub async fn worktree_add_detached(repo: &Path, path: &Path, rev: &str) -> Result<()> {
114 if let Some(parent) = path.parent() {
115 tokio::fs::create_dir_all(parent).await.ok();
116 }
117 let path_s = path.to_string_lossy().to_string();
118 git(repo, &["worktree", "add", "--detach", &path_s, rev])
119 .await
120 .map(|_| ())
121}
122
123pub async fn reset_detached(worktree: &Path, rev: &str) -> Result<()> {
125 git(worktree, &["checkout", "--detach", rev]).await?;
126 git(worktree, &["reset", "--hard", rev]).await?;
127 git(worktree, &["clean", "-fdx"]).await?;
128 Ok(())
129}
130
131pub async fn worktree_remove(repo: &Path, path: &Path) -> Result<bool> {
134 let path_s = path.to_string_lossy().to_string();
135 let out = git_raw(repo, &["worktree", "remove", "--force", &path_s]).await?;
136 if out.ok() {
137 return Ok(true);
138 }
139 git_raw(repo, &["worktree", "prune"]).await?;
141 Ok(false)
142}
143
144pub async fn branch_delete(repo: &Path, branch: &str) -> Result<bool> {
146 Ok(git_raw(repo, &["branch", "-D", branch]).await?.ok())
147}
148
149pub async fn branch_exists(repo: &Path, branch: &str) -> Result<bool> {
151 let refname = format!("refs/heads/{branch}");
152 Ok(
153 git_raw(repo, &["show-ref", "--verify", "--quiet", &refname])
154 .await?
155 .ok(),
156 )
157}
158
159pub async fn diff(worktree: &Path, base: &str, head: &str) -> Result<String> {
161 let range = format!("{base}...{head}");
162 git(
163 worktree,
164 &["diff", "--no-color", "--no-ext-diff", "-M", &range],
165 )
166 .await
167}
168
169pub async fn diff_stat(worktree: &Path, base: &str, head: &str) -> Result<String> {
171 let range = format!("{base}...{head}");
172 git(worktree, &["diff", "--no-color", "--stat", &range]).await
173}
174
175pub async fn changed_files(worktree: &Path, base: &str, head: &str) -> Result<Vec<String>> {
177 let range = format!("{base}...{head}");
178 let out = git(worktree, &["diff", "--name-only", &range]).await?;
179 Ok(out.lines().map(str::to_owned).collect())
180}
181
182pub async fn log_oneline(worktree: &Path, base: &str, head: &str) -> Result<String> {
184 let range = format!("{base}..{head}");
185 git(
186 worktree,
187 &["log", "--reverse", "--format=%s%n%b%n--", &range],
188 )
189 .await
190}
191
192pub async fn commits_ahead(worktree: &Path, base: &str, head: &str) -> Result<usize> {
194 let range = format!("{base}..{head}");
195 let out = git(worktree, &["rev-list", "--count", &range]).await?;
196 Ok(out.trim().parse().unwrap_or(0))
197}
198
199pub async fn commit_all(worktree: &Path, message: &str) -> Result<bool> {
206 if git(worktree, &["status", "--porcelain"]).await?.is_empty() {
207 return Ok(false);
208 }
209 git(worktree, &["add", "-A"]).await?;
210 let out = git_raw(
211 worktree,
212 &[
213 "-c",
214 "user.name=magi candidate",
215 "-c",
216 "user.email=magi@localhost",
217 "commit",
218 "--no-verify",
219 "-m",
220 message,
221 ],
222 )
223 .await?;
224 if !out.ok() {
225 bail!("rescue commit failed: {}", out.stderr);
226 }
227 Ok(true)
228}
229
230pub async fn enable_worktree_config(repo: &Path) -> Result<bool> {
235 let out = git_raw(repo, &["config", "--get", "extensions.worktreeConfig"]).await?;
236 if out.ok() && out.stdout.trim() == "true" {
237 return Ok(false);
238 }
239 git(repo, &["config", "extensions.worktreeConfig", "true"]).await?;
240 Ok(true)
241}
242
243pub async fn disable_worktree_config(repo: &Path) -> Result<()> {
245 git_raw(repo, &["config", "--unset", "extensions.worktreeConfig"]).await?;
246 Ok(())
247}
248
249pub async fn set_worktree_hooks_path(worktree: &Path, hooks_dir: &Path) -> Result<()> {
255 let dir = hooks_dir.to_string_lossy().replace('\\', "/");
256 git(worktree, &["config", "--worktree", "core.hooksPath", &dir])
257 .await
258 .map(|_| ())
259}
260
261pub async fn local_exclude(worktree: &Path, pattern: &str) -> Result<()> {
263 let git_dir = git(worktree, &["rev-parse", "--git-path", "info/exclude"]).await?;
264 let path = worktree.join(git_dir);
265 if let Some(parent) = path.parent() {
266 tokio::fs::create_dir_all(parent).await.ok();
267 }
268 let mut body = tokio::fs::read_to_string(&path).await.unwrap_or_default();
269 if body.lines().any(|l| l.trim() == pattern) {
270 return Ok(());
271 }
272 if !body.is_empty() && !body.ends_with('\n') {
273 body.push('\n');
274 }
275 body.push_str(pattern);
276 body.push('\n');
277 tokio::fs::write(&path, body)
278 .await
279 .with_context(|| format!("write {}", path.display()))?;
280 Ok(())
281}
282
283pub async fn merge_no_ff(repo: &Path, branch: &str, message: &str) -> Result<GitOut> {
285 git_raw(
286 repo,
287 &["merge", "--no-ff", "--no-edit", "-m", message, branch],
288 )
289 .await
290}
291
292pub async fn push(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
294 git_raw(repo, &["push", "-u", remote, branch]).await
295}
296
297pub async fn push_rewritten(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
305 git_raw(repo, &["push", "--force-with-lease", remote, branch]).await
306}
307
308pub async fn rebase_branch_in_temp(
320 repo: &Path,
321 scratch: &Path,
322 branch: &str,
323 onto: &str,
324) -> Result<Option<String>> {
325 worktree_remove(repo, scratch).await.ok();
328 git_raw(
329 repo,
330 &[
331 "worktree",
332 "add",
333 "--force",
334 &scratch.to_string_lossy(),
335 branch,
336 ],
337 )
338 .await?;
339
340 let out = git_raw(scratch, &["rebase", onto]).await?;
341 if out.ok() {
342 worktree_remove(repo, scratch).await.ok();
343 return Ok(None);
344 }
345 git_raw(scratch, &["rebase", "--abort"]).await.ok();
347 let why = if out.stderr.trim().is_empty() {
348 out.stdout.trim().to_owned()
349 } else {
350 out.stderr.trim().to_owned()
351 };
352 worktree_remove(repo, scratch).await.ok();
353 Ok(Some(why))
354}
355
356pub async fn fetch(repo: &Path, remote: &str, branch: &str) -> Result<GitOut> {
375 let refspec = format!("+refs/heads/{branch}:refs/remotes/{remote}/{branch}");
376 git_raw(repo, &["fetch", "--quiet", remote, &refspec]).await
377}
378
379pub async fn rev_exists(repo: &Path, rev: &str) -> bool {
381 git_raw(repo, &["rev-parse", "--verify", "--quiet", rev])
382 .await
383 .is_ok_and(|o| o.ok())
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389
390 async fn scratch() -> (tempfile::TempDir, PathBuf) {
391 let dir = tempfile::tempdir().unwrap();
392 let repo = dir.path().join("repo");
393 tokio::fs::create_dir_all(&repo).await.unwrap();
394 git(&repo, &["init", "-b", "main"]).await.unwrap();
395 git(&repo, &["config", "user.name", "test"]).await.unwrap();
396 git(&repo, &["config", "user.email", "test@example.com"])
397 .await
398 .unwrap();
399 tokio::fs::write(repo.join("a.txt"), "one\n").await.unwrap();
400 git(&repo, &["add", "-A"]).await.unwrap();
401 git(&repo, &["commit", "-m", "init"]).await.unwrap();
402 (dir, repo)
403 }
404
405 #[tokio::test]
406 async fn a_branch_rebases_onto_a_moved_base_and_says_when_it_cannot() {
407 let (_g, repo) = scratch().await;
408
409 git(&repo, &["checkout", "-b", "side"]).await.unwrap();
411 tokio::fs::write(repo.join("b.txt"), "side\n")
412 .await
413 .unwrap();
414 git(&repo, &["add", "-A"]).await.unwrap();
415 git(&repo, &["commit", "-m", "side work"]).await.unwrap();
416
417 git(&repo, &["checkout", "main"]).await.unwrap();
420 tokio::fs::write(repo.join("c.txt"), "main\n")
421 .await
422 .unwrap();
423 git(&repo, &["add", "-A"]).await.unwrap();
424 git(&repo, &["commit", "-m", "main moved"]).await.unwrap();
425
426 let scratch_tree = repo.parent().unwrap().join("rebase-scratch");
427 let clean = rebase_branch_in_temp(&repo, &scratch_tree, "side", "main")
428 .await
429 .unwrap();
430 assert!(clean.is_none(), "a disjoint change rebases: {clean:?}");
431 assert_eq!(
432 commits_ahead(&repo, "main", "side").await.unwrap(),
433 1,
434 "one commit, replayed onto the new base"
435 );
436 assert!(
437 !scratch_tree.exists(),
438 "the throwaway worktree is not left behind"
439 );
440
441 git(&repo, &["checkout", "-b", "clash"]).await.unwrap();
443 tokio::fs::write(repo.join("a.txt"), "clash\n")
444 .await
445 .unwrap();
446 git(&repo, &["add", "-A"]).await.unwrap();
447 git(&repo, &["commit", "-m", "clash"]).await.unwrap();
448 git(&repo, &["checkout", "main"]).await.unwrap();
449 tokio::fs::write(repo.join("a.txt"), "main edit\n")
450 .await
451 .unwrap();
452 git(&repo, &["add", "-A"]).await.unwrap();
453 git(&repo, &["commit", "-m", "main edit"]).await.unwrap();
454
455 let before = rev_parse(&repo, "clash").await.unwrap();
456 let why = rebase_branch_in_temp(&repo, &scratch_tree, "clash", "main")
457 .await
458 .unwrap()
459 .expect("a same-line clash cannot be rebased silently");
460 assert!(
461 why.to_lowercase().contains("conflict"),
462 "the reason is what git said, which is what a person needs: {why}"
463 );
464 assert_eq!(
465 rev_parse(&repo, "clash").await.unwrap(),
466 before,
467 "a failed rebase leaves the branch exactly where it was"
468 );
469 assert!(!scratch_tree.exists(), "and cleans up after itself");
470 }
471
472 #[tokio::test]
473 async fn clean_repo_reports_clean_then_dirty() {
474 let (_g, repo) = scratch().await;
475 assert!(is_clean(&repo).await.unwrap());
476 tokio::fs::write(repo.join("a.txt"), "two\n").await.unwrap();
477 assert!(!is_clean(&repo).await.unwrap());
478 }
479
480 #[tokio::test]
481 async fn worktree_lifecycle_and_diff() {
482 let (guard, repo) = scratch().await;
483 let base = rev_parse(&repo, "HEAD").await.unwrap();
484 let wt = guard.path().join("wt-a");
485 worktree_add_branch(&repo, &wt, "magi/test/a", &base)
486 .await
487 .unwrap();
488 tokio::fs::write(wt.join("b.txt"), "candidate\n")
489 .await
490 .unwrap();
491
492 assert!(commit_all(&wt, "candidate work").await.unwrap());
493 assert!(!commit_all(&wt, "nothing left").await.unwrap());
494
495 assert_eq!(commits_ahead(&wt, &base, "HEAD").await.unwrap(), 1);
496 let patch = diff(&wt, &base, "HEAD").await.unwrap();
497 assert!(patch.contains("b.txt"), "patch was: {patch}");
498 assert_eq!(
499 changed_files(&wt, &base, "HEAD").await.unwrap(),
500 ["b.txt".to_owned()]
501 );
502
503 let author = git(&wt, &["log", "-1", "--format=%an <%ae>"])
505 .await
506 .unwrap();
507 assert_eq!(author, "magi candidate <magi@localhost>");
508
509 assert!(worktree_remove(&repo, &wt).await.unwrap());
510 assert!(branch_exists(&repo, "magi/test/a").await.unwrap());
511 assert!(branch_delete(&repo, "magi/test/a").await.unwrap());
512 assert!(!branch_exists(&repo, "magi/test/a").await.unwrap());
513 }
514
515 #[tokio::test]
516 async fn worktree_scoped_hooks_path_does_not_leak_to_primary() {
517 let (guard, repo) = scratch().await;
518 let base = rev_parse(&repo, "HEAD").await.unwrap();
519 let wt = guard.path().join("wt-h");
520 worktree_add_branch(&repo, &wt, "magi/test/h", &base)
521 .await
522 .unwrap();
523 let hooks = guard.path().join("hooks");
524 tokio::fs::create_dir_all(&hooks).await.unwrap();
525
526 assert!(enable_worktree_config(&repo).await.unwrap());
527 set_worktree_hooks_path(&wt, &hooks).await.unwrap();
528
529 let in_wt = git(&wt, &["config", "--get", "core.hooksPath"])
530 .await
531 .unwrap();
532 assert!(!in_wt.is_empty());
533 let in_primary = git_raw(&repo, &["config", "--get", "core.hooksPath"])
534 .await
535 .unwrap();
536 assert!(
537 !in_primary.ok(),
538 "primary worktree must keep its own hooks: {in_primary:?}"
539 );
540
541 disable_worktree_config(&repo).await.unwrap();
542 }
543
544 #[tokio::test]
545 async fn local_exclude_is_idempotent() {
546 let (_g, repo) = scratch().await;
547 local_exclude(&repo, "/.magi/").await.unwrap();
548 local_exclude(&repo, "/.magi/").await.unwrap();
549 let path = repo.join(".git/info/exclude");
550 let body = tokio::fs::read_to_string(&path).await.unwrap();
551 assert_eq!(body.matches("/.magi/").count(), 1);
552 }
553}