1use std::path::{Component, Path, PathBuf};
22use std::time::Duration;
23
24use anyhow::{Context, Result, bail};
25
26use crate::config::{Config, SetupStep};
27use crate::git;
28use crate::proc::Quiet as _;
29
30pub const RECORD: &str = "magi-setup-paths";
32const EXCLUDE: &str = "magi-setup-exclude";
34const TAIL: usize = 4000;
36
37pub async fn prepare(cfg: &Config, repo: &Path, wt: &Path) -> Result<()> {
42 let steps = &cfg.worktree.setup;
43 if steps.is_empty() {
44 return Ok(());
45 }
46 let total = steps.len();
47 let mut copied: Vec<String> = Vec::new();
48 for (i, step) in steps.iter().enumerate() {
49 let n = i + 1;
50 let what = step.describe();
51 match (&step.copy, &step.run) {
52 (Some(spec), None) => {
53 let made = copy_step(repo, wt, spec, step.optional)
54 .await
55 .with_context(|| format!("worktree setup step {n}/{total} ({what}) failed"))?;
56 copied.extend(made);
57 }
58 (None, Some(command)) => {
59 let secs = step
60 .timeout_secs
61 .unwrap_or_else(|| cfg.graph.verify_timeout());
62 run_step(&cfg.shell(), command, wt, Duration::from_secs(secs))
63 .await
64 .with_context(|| format!("worktree setup step {n}/{total} ({what}) failed"))?;
65 }
66 _ => bail!("worktree setup step {n}/{total} must set exactly one of copy / run"),
67 }
68 }
69 seal(wt, &copied).await.context("worktree setup")
70}
71
72fn split_copy(spec: &str) -> (&str, &str) {
74 match spec.split_once("->") {
75 Some((a, b)) => (a.trim(), b.trim()),
76 None => (spec.trim(), spec.trim()),
77 }
78}
79
80pub fn valid_destination(p: &str) -> Result<()> {
82 if p.is_empty() {
83 bail!("destination is empty");
84 }
85 let path = Path::new(p);
86 if path.is_absolute() || p.starts_with('/') || p.starts_with('\\') || p.contains(':') {
87 bail!("destination `{p}` must be relative to the worktree");
88 }
89 let mut first = true;
90 for c in path.components() {
91 match c {
92 Component::Normal(s) => {
93 if first && s.eq_ignore_ascii_case(".git") {
94 bail!("destination `{p}` is inside .git");
95 }
96 first = false;
97 }
98 Component::CurDir => {}
99 _ => bail!("destination `{p}` must not contain `..`"),
100 }
101 }
102 Ok(())
103}
104
105pub fn validate_copy(spec: &str) -> Result<()> {
107 let (src, dst) = split_copy(spec);
108 if src.is_empty() {
109 bail!("copy `{spec}` has no source");
110 }
111 valid_destination(dst)
112}
113
114async fn copy_step(repo: &Path, wt: &Path, spec: &str, optional: bool) -> Result<Option<String>> {
116 let (src, dst) = split_copy(spec);
117 validate_copy(spec)?;
118 let src_path = if Path::new(src).is_absolute() {
119 PathBuf::from(src)
120 } else {
121 repo.join(src)
122 };
123 let dst_path = wt.join(dst);
124 let mut probe = wt.to_path_buf();
126 for c in Path::new(dst).components() {
127 if let Component::Normal(s) = c {
128 probe.push(s);
129 if let Ok(meta) = tokio::fs::symlink_metadata(&probe).await
130 && meta.file_type().is_symlink()
131 {
132 bail!("destination `{dst}` goes through a symlink");
133 }
134 }
135 }
136 let data = match tokio::fs::read(&src_path).await {
137 Ok(d) => d,
138 Err(e) if e.kind() == std::io::ErrorKind::NotFound && optional => return Ok(None),
139 Err(e) => bail!("cannot read source {}: {e}", src_path.display()),
140 };
141 if let Some(parent) = dst_path.parent() {
142 tokio::fs::create_dir_all(parent)
143 .await
144 .with_context(|| format!("create {}", parent.display()))?;
145 }
146 use tokio::io::AsyncWriteExt as _;
148 match tokio::fs::OpenOptions::new()
149 .write(true)
150 .create_new(true)
151 .open(&dst_path)
152 .await
153 {
154 Ok(mut f) => {
155 f.write_all(&data).await?;
156 f.flush().await?;
157 #[cfg(unix)]
158 if let Ok(meta) = tokio::fs::metadata(&src_path).await {
159 tokio::fs::set_permissions(&dst_path, meta.permissions())
160 .await
161 .ok();
162 }
163 Ok(Some(dst.trim_start_matches("./").to_owned()))
164 }
165 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(None),
166 Err(e) => bail!("cannot create {}: {e}", dst_path.display()),
167 }
168}
169
170fn tail(s: &str) -> &str {
171 let s = s.trim();
172 if s.len() <= TAIL {
173 return s;
174 }
175 let mut at = s.len() - TAIL;
176 while !s.is_char_boundary(at) {
177 at += 1;
178 }
179 &s[at..]
180}
181
182async fn run_step(shell: &[String], command: &str, wt: &Path, timeout: Duration) -> Result<()> {
183 static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
184 let log = std::env::temp_dir().join(format!(
185 "magi-setup-{}-{}.out",
186 std::process::id(),
187 SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
188 ));
189 let out = std::fs::File::create(&log).context("create output file")?;
190 let err = out.try_clone()?;
191 let mut cmd = tokio::process::Command::new(&shell[0]);
192 cmd.quiet();
193 cmd.args(&shell[1..])
194 .arg(command)
195 .current_dir(wt)
196 .env_remove("MAGI_RUN")
197 .env_remove("MAGI_NODE")
198 .stdin(std::process::Stdio::null())
199 .stdout(out)
200 .stderr(err)
201 .kill_on_drop(true);
202 #[cfg(unix)]
203 cmd.process_group(0);
204 let mut child = cmd
205 .spawn()
206 .with_context(|| format!("spawn `{}` for `{command}`", shell[0]))?;
207 let pid = child.id();
208 let waited = tokio::time::timeout(timeout, child.wait()).await;
209 let body = || tail(&std::fs::read_to_string(&log).unwrap_or_default()).to_owned();
210 let result = match waited {
211 Ok(Ok(status)) if status.success() => Ok(()),
212 Ok(Ok(status)) => Err(anyhow::anyhow!(
213 "`{command}` exited with {status}\n{}",
214 body()
215 )),
216 Ok(Err(e)) => Err(anyhow::anyhow!("`{command}` could not be awaited: {e}")),
217 Err(_) => {
218 kill_tree(pid, &mut child).await;
219 Err(anyhow::anyhow!(
220 "`{command}` timed out after {}s\n{}",
221 timeout.as_secs(),
222 body()
223 ))
224 }
225 };
226 std::fs::remove_file(&log).ok();
227 result
228}
229
230async fn kill_tree(pid: Option<u32>, child: &mut tokio::process::Child) {
232 #[cfg(unix)]
233 if let Some(pid) = pid {
234 let mut kill = tokio::process::Command::new("kill");
235 kill.args(["-KILL", "--", &format!("-{pid}")]);
236 kill.status().await.ok();
237 }
238 #[cfg(not(unix))]
239 let _ = pid;
240 child.start_kill().ok();
241 child.wait().await.ok();
242}
243
244async fn git_dir(wt: &Path) -> Result<PathBuf> {
245 Ok(PathBuf::from(
246 git::git(wt, &["rev-parse", "--absolute-git-dir"])
247 .await?
248 .trim(),
249 ))
250}
251
252pub async fn withheld_paths(wt: &Path) -> Vec<String> {
254 let Ok(dir) = git_dir(wt).await else {
255 return Vec::new();
256 };
257 let Ok(raw) = tokio::fs::read(dir.join(RECORD)).await else {
258 return Vec::new();
259 };
260 raw.split(|b| *b == 0)
261 .filter(|p| !p.is_empty())
262 .map(|p| String::from_utf8_lossy(p).into_owned())
263 .collect()
264}
265
266pub async fn unstage_products(wt: &Path) -> Result<()> {
268 let paths = withheld_paths(wt).await;
269 if paths.is_empty() {
270 return Ok(());
271 }
272 let mut args = vec!["reset", "-q", "--"];
273 args.extend(paths.iter().map(String::as_str));
274 git::git(wt, &args).await.map(|_| ())
275}
276
277async fn seal(wt: &Path, copied: &[String]) -> Result<()> {
280 let tracked = git::git(wt, &["diff", "--name-only", "HEAD"]).await?;
281 if !tracked.trim().is_empty() {
282 bail!(
283 "setup must not change tracked files (every seat would carry the same diff), \
284 but it changed:\n{}",
285 tail(&tracked)
286 );
287 }
288 let out = git::git(wt, &["ls-files", "-z", "--others", "--exclude-standard"]).await?;
289 let mut paths: Vec<String> = withheld_paths(wt).await;
290 for p in copied
294 .iter()
295 .map(String::as_str)
296 .chain(out.split('\0').filter(|p| !p.is_empty()))
297 {
298 if !paths.iter().any(|q| q == p) {
299 paths.push(p.to_owned());
300 }
301 }
302 if paths.is_empty() {
303 return Ok(());
304 }
305 let dir = git_dir(wt).await?;
306 let mut raw = Vec::new();
307 for p in &paths {
308 raw.extend_from_slice(p.as_bytes());
309 raw.push(0);
310 }
311 tokio::fs::write(dir.join(RECORD), raw).await?;
312 hide(wt, &dir, &paths).await
313}
314
315fn exclude_line(p: &str) -> String {
316 let mut s = String::from("/");
317 for c in p.chars() {
318 if matches!(c, '*' | '?' | '[' | ']' | '\\' | '#' | '!' | ' ') {
319 s.push('\\');
320 }
321 s.push(c);
322 }
323 s
324}
325
326async fn hide(wt: &Path, dir: &Path, paths: &[String]) -> Result<()> {
332 let ext = git::git_raw(wt, &["config", "--get", "extensions.worktreeConfig"]).await?;
333 if !(ext.ok() && ext.stdout.trim() == "true") {
334 bail!(
335 "setup left untracked files ({}) but extensions.worktreeConfig is off, so they \
336 cannot be hidden from `git add` per worktree",
337 paths.join(", ")
338 );
339 }
340 let file = dir.join(EXCLUDE);
341 let file_s = file.to_string_lossy().replace('\\', "/");
342 let current = git::git_raw(wt, &["config", "--get", "core.excludesFile"])
343 .await
344 .ok()
345 .filter(|o| o.ok())
346 .map(|o| o.stdout.trim().to_owned())
347 .filter(|s| !s.is_empty());
348 let mut body = match current {
349 Some(c) if c == file_s => std::fs::read_to_string(&file).unwrap_or_default(),
350 Some(c) => {
351 let c = match c.strip_prefix("~/") {
352 Some(rest) => std::env::var_os("HOME")
353 .map(|h| PathBuf::from(h).join(rest))
354 .unwrap_or_else(|| PathBuf::from(&c)),
355 None => PathBuf::from(&c),
356 };
357 std::fs::read_to_string(c).unwrap_or_default()
358 }
359 None => String::new(),
360 };
361 if !body.is_empty() && !body.ends_with('\n') {
362 body.push('\n');
363 }
364 for p in paths {
365 let line = exclude_line(p);
366 if !body.lines().any(|l| l == line) {
367 body.push_str(&line);
368 body.push('\n');
369 }
370 }
371 std::fs::write(&file, body).with_context(|| format!("write {}", file.display()))?;
372 git::git(wt, &["config", "--worktree", "core.excludesFile", &file_s])
373 .await
374 .map(|_| ())
375}
376
377impl SetupStep {
378 pub fn describe(&self) -> String {
380 match (&self.copy, &self.run) {
381 (Some(c), _) => format!("copy {c}"),
382 (_, Some(r)) => format!("run {r}"),
383 _ => "empty".to_owned(),
384 }
385 }
386}
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391 use crate::config::SetupStep;
392
393 fn copy(spec: &str) -> SetupStep {
394 SetupStep {
395 copy: Some(spec.to_owned()),
396 ..Default::default()
397 }
398 }
399
400 fn run(cmd: &str) -> SetupStep {
401 SetupStep {
402 run: Some(cmd.to_owned()),
403 ..Default::default()
404 }
405 }
406
407 fn cfg(steps: Vec<SetupStep>) -> Config {
408 let mut c = Config::default();
409 c.worktree.setup = steps;
410 c.verify.shell = Some(vec!["sh".to_owned(), "-c".to_owned()]);
411 c
412 }
413
414 async fn scratch() -> (tempfile::TempDir, PathBuf, PathBuf) {
416 let tmp = tempfile::tempdir().unwrap();
417 let repo = tmp.path().join("repo");
418 std::fs::create_dir_all(&repo).unwrap();
419 for args in [
420 vec!["init", "-b", "main"],
421 vec!["config", "user.name", "t"],
422 vec!["config", "user.email", "t@example.com"],
423 ] {
424 git::git(&repo, &args).await.unwrap();
425 }
426 std::fs::write(repo.join("a.txt"), "a\n").unwrap();
427 git::git(&repo, &["add", "-A"]).await.unwrap();
428 git::git(&repo, &["commit", "-m", "init"]).await.unwrap();
429 let wt = tmp.path().join("wt");
430 git::worktree_add_detached(&repo, &wt, "HEAD")
431 .await
432 .unwrap();
433 git::acquire_worktree_config(&repo).await.unwrap();
435 (tmp, repo, wt)
436 }
437
438 #[tokio::test]
439 async fn default_is_a_no_op() {
440 let (_t, repo, wt) = scratch().await;
441 prepare(&Config::default(), &repo, &wt).await.unwrap();
442 assert!(withheld_paths(&wt).await.is_empty());
443 }
444
445 #[tokio::test]
446 async fn copy_then_run_in_order() {
447 let (_t, repo, wt) = scratch().await;
448 std::fs::write(repo.join(".env.example"), "KEY=1\n").unwrap();
449 let c = cfg(vec![
450 copy(".env.example -> .env"),
451 run("cat .env > seen.txt && echo ran >> seen.txt"),
452 ]);
453 prepare(&c, &repo, &wt).await.unwrap();
454 assert_eq!(
455 std::fs::read_to_string(wt.join("seen.txt")).unwrap(),
456 "KEY=1\nran\n"
457 );
458 let mut got = withheld_paths(&wt).await;
459 got.sort();
460 assert_eq!(got, [".env", "seen.txt"]);
461 }
462
463 #[tokio::test]
464 async fn a_failing_step_stops_and_names_step_and_output() {
465 let (_t, repo, wt) = scratch().await;
466 let c = cfg(vec![run("echo boom >&2; exit 3"), run("touch after.txt")]);
467 let err = format!("{:#}", prepare(&c, &repo, &wt).await.unwrap_err());
468 assert!(err.contains("step 1/2"), "{err}");
469 assert!(err.contains("boom"), "{err}");
470 assert!(!wt.join("after.txt").exists());
471 }
472
473 #[tokio::test]
474 async fn a_timeout_keeps_the_partial_output() {
475 let (_t, repo, wt) = scratch().await;
476 let mut step = run("echo partial; sleep 30");
477 step.timeout_secs = Some(1);
478 let err = format!(
479 "{:#}",
480 prepare(&cfg(vec![step]), &repo, &wt).await.unwrap_err()
481 );
482 assert!(err.contains("timed out"), "{err}");
483 assert!(err.contains("partial"), "{err}");
484 }
485
486 #[tokio::test]
487 async fn copy_never_overwrites() {
488 let (_t, repo, wt) = scratch().await;
489 std::fs::write(repo.join("src.txt"), "new\n").unwrap();
490 std::fs::write(wt.join("dst.txt"), "mine\n").unwrap();
491 prepare(&cfg(vec![copy("src.txt -> dst.txt")]), &repo, &wt)
492 .await
493 .unwrap();
494 assert_eq!(
495 std::fs::read_to_string(wt.join("dst.txt")).unwrap(),
496 "mine\n"
497 );
498 }
499
500 #[tokio::test]
501 async fn a_missing_source_is_an_error_unless_optional() {
502 let (_t, repo, wt) = scratch().await;
503 let err = format!(
504 "{:#}",
505 prepare(&cfg(vec![copy("nope")]), &repo, &wt)
506 .await
507 .unwrap_err()
508 );
509 assert!(err.contains("step 1/1") && err.contains("nope"), "{err}");
510 let mut opt = copy("nope");
511 opt.optional = true;
512 prepare(&cfg(vec![opt]), &repo, &wt).await.unwrap();
513 assert!(!wt.join("nope").exists());
514 }
515
516 #[tokio::test]
517 async fn setup_may_not_edit_tracked_files() {
518 let (_t, repo, wt) = scratch().await;
519 let err = format!(
520 "{:#}",
521 prepare(&cfg(vec![run("echo x >> a.txt")]), &repo, &wt)
522 .await
523 .unwrap_err()
524 );
525 assert!(err.contains("tracked") && err.contains("a.txt"), "{err}");
526 }
527
528 #[tokio::test]
529 async fn the_command_does_not_see_the_run_identity() {
530 let (_t, repo, wt) = scratch().await;
531 unsafe {
533 std::env::set_var("MAGI_RUN", "r1");
534 std::env::set_var("MAGI_NODE", "n1");
535 }
536 prepare(
537 &cfg(vec![run("echo \"[$MAGI_RUN][$MAGI_NODE]\" > ids.txt")]),
538 &repo,
539 &wt,
540 )
541 .await
542 .unwrap();
543 assert_eq!(
544 std::fs::read_to_string(wt.join("ids.txt")).unwrap(),
545 "[][]\n"
546 );
547 }
548
549 #[tokio::test]
550 async fn products_are_kept_out_of_both_commit_paths() {
551 let (_t, repo, wt) = scratch().await;
552 std::fs::write(repo.join("secret"), "s\n").unwrap();
553 prepare(&cfg(vec![copy("secret")]), &repo, &wt)
554 .await
555 .unwrap();
556
557 git::git(&wt, &["add", "-A"]).await.unwrap();
559 assert!(
560 git::git(&wt, &["diff", "--cached", "--name-only"])
561 .await
562 .unwrap()
563 .is_empty()
564 );
565
566 assert!(!git::commit_all(&wt, "rescue").await.unwrap());
568 let r = git::rescue_commit(&wt, "rescue").await.unwrap();
569 assert!(!r.committed);
570
571 std::fs::write(wt.join("work.txt"), "w\n").unwrap();
573 assert!(git::commit_all(&wt, "rescue").await.unwrap());
574 let files = git::git(&wt, &["show", "--name-only", "--format=", "HEAD"])
575 .await
576 .unwrap();
577 assert_eq!(files.trim(), "work.txt");
578 }
579
580 #[tokio::test]
581 async fn products_without_worktree_config_fail_loudly_and_leave_shared_config_alone() {
582 let (_t, repo, wt) = scratch().await;
583 git::release_worktree_config(&repo).await.unwrap();
584 std::fs::write(repo.join("secret"), "s\n").unwrap();
585 let err = format!(
586 "{:#}",
587 prepare(&cfg(vec![copy("secret")]), &repo, &wt)
588 .await
589 .unwrap_err()
590 );
591 assert!(err.contains("worktreeConfig"), "{err}");
592 let shared = git::git_raw(&repo, &["config", "--get", "core.excludesFile"])
593 .await
594 .unwrap();
595 assert!(!shared.ok(), "shared config was written: {}", shared.stdout);
596 }
597
598 #[tokio::test]
599 async fn an_ignored_copy_is_still_withheld_from_commits() {
600 let (_t, repo, wt) = scratch().await;
601 std::fs::write(wt.join(".gitignore"), ".env\n").unwrap();
602 git::git(&wt, &["add", ".gitignore"]).await.unwrap();
603 git::git(&wt, &["commit", "-m", "ignore"]).await.unwrap();
604 std::fs::write(repo.join(".env"), "s\n").unwrap();
605 prepare(&cfg(vec![copy(".env")]), &repo, &wt).await.unwrap();
606 assert_eq!(withheld_paths(&wt).await, [".env"]);
607 git::git(&wt, &["add", "-f", ".env"]).await.unwrap();
609 std::fs::write(wt.join("work.txt"), "w\n").unwrap();
610 assert!(git::commit_all(&wt, "rescue").await.unwrap());
611 let files = git::git(&wt, &["show", "--name-only", "--format=", "HEAD"])
612 .await
613 .unwrap();
614 assert_eq!(files.trim(), "work.txt");
615 }
616
617 #[test]
618 fn destinations_must_stay_inside() {
619 for bad in ["/etc/x", "../x", "a/../../x", ".git/hooks/x", "C:/x", ""] {
620 assert!(valid_destination(bad).is_err(), "{bad}");
621 }
622 assert!(valid_destination("a/b.env").is_ok());
623 }
624}