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 crate::incremental::configure(&mut cmd, wt)?;
203 let mut running = crate::seat_process::Running::spawn_operation(cmd, None, Vec::new())
204 .with_context(|| format!("spawn `{}` for `{command}`", shell[0]))?;
205 let waited = tokio::time::timeout(timeout, running.child().wait()).await;
206 running.finish().await;
210 let body = || tail(&std::fs::read_to_string(&log).unwrap_or_default()).to_owned();
211 let result = match waited {
212 Ok(Ok(status)) if status.success() => Ok(()),
213 Ok(Ok(status)) => Err(anyhow::anyhow!(
214 "`{command}` exited with {status}\n{}",
215 body()
216 )),
217 Ok(Err(e)) => Err(anyhow::anyhow!("`{command}` could not be awaited: {e}")),
218 Err(_) => Err(anyhow::anyhow!(
219 "`{command}` timed out after {}s\n{}",
220 timeout.as_secs(),
221 body()
222 )),
223 };
224 std::fs::remove_file(&log).ok();
225 result
226}
227
228async fn git_dir(wt: &Path) -> Result<PathBuf> {
229 Ok(PathBuf::from(
230 git::git(wt, &["rev-parse", "--absolute-git-dir"])
231 .await?
232 .trim(),
233 ))
234}
235
236pub async fn withheld_paths(wt: &Path) -> Vec<String> {
238 let Ok(dir) = git_dir(wt).await else {
239 return Vec::new();
240 };
241 let Ok(raw) = tokio::fs::read(dir.join(RECORD)).await else {
242 return Vec::new();
243 };
244 raw.split(|b| *b == 0)
245 .filter(|p| !p.is_empty())
246 .map(|p| String::from_utf8_lossy(p).into_owned())
247 .collect()
248}
249
250pub async fn unstage_products(wt: &Path) -> Result<()> {
252 let paths = withheld_paths(wt).await;
253 if paths.is_empty() {
254 return Ok(());
255 }
256 let mut args = vec!["reset", "-q", "--"];
257 args.extend(paths.iter().map(String::as_str));
258 git::git(wt, &args).await.map(|_| ())
259}
260
261async fn seal(wt: &Path, copied: &[String]) -> Result<()> {
264 let tracked = git::git(wt, &["diff", "--name-only", "HEAD"]).await?;
265 if !tracked.trim().is_empty() {
266 bail!(
267 "setup must not change tracked files (every seat would carry the same diff), \
268 but it changed:\n{}",
269 tail(&tracked)
270 );
271 }
272 let out = git::git(wt, &["ls-files", "-z", "--others", "--exclude-standard"]).await?;
273 let mut paths: Vec<String> = withheld_paths(wt).await;
274 for p in copied
278 .iter()
279 .map(String::as_str)
280 .chain(out.split('\0').filter(|p| !p.is_empty()))
281 {
282 if !paths.iter().any(|q| q == p) {
283 paths.push(p.to_owned());
284 }
285 }
286 if paths.is_empty() {
287 return Ok(());
288 }
289 let dir = git_dir(wt).await?;
290 let mut raw = Vec::new();
291 for p in &paths {
292 raw.extend_from_slice(p.as_bytes());
293 raw.push(0);
294 }
295 tokio::fs::write(dir.join(RECORD), raw).await?;
296 hide(wt, &dir, &paths).await
297}
298
299fn exclude_line(p: &str) -> String {
300 let mut s = String::from("/");
301 for c in p.chars() {
302 if matches!(c, '*' | '?' | '[' | ']' | '\\' | '#' | '!' | ' ') {
303 s.push('\\');
304 }
305 s.push(c);
306 }
307 s
308}
309
310async fn hide(wt: &Path, dir: &Path, paths: &[String]) -> Result<()> {
316 let ext = git::git_raw(wt, &["config", "--get", "extensions.worktreeConfig"]).await?;
317 if !(ext.ok() && ext.stdout.trim() == "true") {
318 bail!(
319 "setup left untracked files ({}) but extensions.worktreeConfig is off, so they \
320 cannot be hidden from `git add` per worktree",
321 paths.join(", ")
322 );
323 }
324 let file = dir.join(EXCLUDE);
325 let file_s = file.to_string_lossy().replace('\\', "/");
326 let current = git::git_raw(wt, &["config", "--get", "core.excludesFile"])
327 .await
328 .ok()
329 .filter(|o| o.ok())
330 .map(|o| o.stdout.trim().to_owned())
331 .filter(|s| !s.is_empty());
332 let mut body = match current {
333 Some(c) if c == file_s => std::fs::read_to_string(&file).unwrap_or_default(),
334 Some(c) => {
335 let c = match c.strip_prefix("~/") {
336 Some(rest) => std::env::var_os("HOME")
337 .map(|h| PathBuf::from(h).join(rest))
338 .unwrap_or_else(|| PathBuf::from(&c)),
339 None => PathBuf::from(&c),
340 };
341 std::fs::read_to_string(c).unwrap_or_default()
342 }
343 None => String::new(),
344 };
345 if !body.is_empty() && !body.ends_with('\n') {
346 body.push('\n');
347 }
348 for p in paths {
349 let line = exclude_line(p);
350 if !body.lines().any(|l| l == line) {
351 body.push_str(&line);
352 body.push('\n');
353 }
354 }
355 std::fs::write(&file, body).with_context(|| format!("write {}", file.display()))?;
356 git::git(wt, &["config", "--worktree", "core.excludesFile", &file_s])
357 .await
358 .map(|_| ())
359}
360
361impl SetupStep {
362 pub fn describe(&self) -> String {
364 match (&self.copy, &self.run) {
365 (Some(c), _) => format!("copy {c}"),
366 (_, Some(r)) => format!("run {r}"),
367 _ => "empty".to_owned(),
368 }
369 }
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375 use crate::config::SetupStep;
376
377 fn copy(spec: &str) -> SetupStep {
378 SetupStep {
379 copy: Some(spec.to_owned()),
380 ..Default::default()
381 }
382 }
383
384 fn run(cmd: &str) -> SetupStep {
385 SetupStep {
386 run: Some(cmd.to_owned()),
387 ..Default::default()
388 }
389 }
390
391 fn cfg(steps: Vec<SetupStep>) -> Config {
392 let mut c = Config::default();
393 c.worktree.setup = steps;
394 c.verify.shell = Some(vec!["sh".to_owned(), "-c".to_owned()]);
395 c
396 }
397
398 async fn scratch() -> (tempfile::TempDir, PathBuf, PathBuf) {
400 let tmp = tempfile::tempdir().unwrap();
401 let repo = tmp.path().join("repo");
402 std::fs::create_dir_all(&repo).unwrap();
403 for args in [
404 vec!["init", "-b", "main"],
405 vec!["config", "user.name", "t"],
406 vec!["config", "user.email", "t@example.com"],
407 ] {
408 git::git(&repo, &args).await.unwrap();
409 }
410 std::fs::write(repo.join("a.txt"), "a\n").unwrap();
411 git::git(&repo, &["add", "-A"]).await.unwrap();
412 git::git(&repo, &["commit", "-m", "init"]).await.unwrap();
413 let wt = tmp.path().join("wt");
414 git::worktree_add_detached(&repo, &wt, "HEAD")
415 .await
416 .unwrap();
417 git::acquire_worktree_config(&repo).await.unwrap();
419 (tmp, repo, wt)
420 }
421
422 #[tokio::test]
423 async fn default_is_a_no_op() {
424 let (_t, repo, wt) = scratch().await;
425 prepare(&Config::default(), &repo, &wt).await.unwrap();
426 assert!(withheld_paths(&wt).await.is_empty());
427 }
428
429 #[tokio::test]
430 async fn copy_then_run_in_order() {
431 let (_t, repo, wt) = scratch().await;
432 std::fs::write(repo.join(".env.example"), "KEY=1\n").unwrap();
433 let c = cfg(vec![
434 copy(".env.example -> .env"),
435 run("cat .env > seen.txt && echo ran >> seen.txt"),
436 ]);
437 prepare(&c, &repo, &wt).await.unwrap();
438 assert_eq!(
439 std::fs::read_to_string(wt.join("seen.txt")).unwrap(),
440 "KEY=1\nran\n"
441 );
442 let mut got = withheld_paths(&wt).await;
443 got.sort();
444 assert_eq!(got, [".env", "seen.txt"]);
445 }
446
447 #[tokio::test]
448 async fn a_failing_step_stops_and_names_step_and_output() {
449 let (_t, repo, wt) = scratch().await;
450 let c = cfg(vec![run("echo boom >&2; exit 3"), run("touch after.txt")]);
451 let err = format!("{:#}", prepare(&c, &repo, &wt).await.unwrap_err());
452 assert!(err.contains("step 1/2"), "{err}");
453 assert!(err.contains("boom"), "{err}");
454 assert!(!wt.join("after.txt").exists());
455 }
456
457 #[tokio::test]
458 async fn a_timeout_keeps_the_partial_output() {
459 let (_t, repo, wt) = scratch().await;
460 let mut step = run("echo partial; sleep 30");
461 step.timeout_secs = Some(1);
462 let err = format!(
463 "{:#}",
464 prepare(&cfg(vec![step]), &repo, &wt).await.unwrap_err()
465 );
466 assert!(err.contains("timed out"), "{err}");
467 assert!(err.contains("partial"), "{err}");
468 }
469
470 #[tokio::test]
471 async fn copy_never_overwrites() {
472 let (_t, repo, wt) = scratch().await;
473 std::fs::write(repo.join("src.txt"), "new\n").unwrap();
474 std::fs::write(wt.join("dst.txt"), "mine\n").unwrap();
475 prepare(&cfg(vec![copy("src.txt -> dst.txt")]), &repo, &wt)
476 .await
477 .unwrap();
478 assert_eq!(
479 std::fs::read_to_string(wt.join("dst.txt")).unwrap(),
480 "mine\n"
481 );
482 }
483
484 #[tokio::test]
485 async fn a_missing_source_is_an_error_unless_optional() {
486 let (_t, repo, wt) = scratch().await;
487 let err = format!(
488 "{:#}",
489 prepare(&cfg(vec![copy("nope")]), &repo, &wt)
490 .await
491 .unwrap_err()
492 );
493 assert!(err.contains("step 1/1") && err.contains("nope"), "{err}");
494 let mut opt = copy("nope");
495 opt.optional = true;
496 prepare(&cfg(vec![opt]), &repo, &wt).await.unwrap();
497 assert!(!wt.join("nope").exists());
498 }
499
500 #[tokio::test]
501 async fn setup_may_not_edit_tracked_files() {
502 let (_t, repo, wt) = scratch().await;
503 let err = format!(
504 "{:#}",
505 prepare(&cfg(vec![run("echo x >> a.txt")]), &repo, &wt)
506 .await
507 .unwrap_err()
508 );
509 assert!(err.contains("tracked") && err.contains("a.txt"), "{err}");
510 }
511
512 #[tokio::test]
513 async fn the_command_does_not_see_the_run_identity() {
514 let (_t, repo, wt) = scratch().await;
515 unsafe {
517 std::env::set_var("MAGI_RUN", "r1");
518 std::env::set_var("MAGI_NODE", "n1");
519 }
520 prepare(
521 &cfg(vec![run("echo \"[$MAGI_RUN][$MAGI_NODE]\" > ids.txt")]),
522 &repo,
523 &wt,
524 )
525 .await
526 .unwrap();
527 assert_eq!(
528 std::fs::read_to_string(wt.join("ids.txt")).unwrap(),
529 "[][]\n"
530 );
531 }
532
533 #[tokio::test]
534 async fn products_are_kept_out_of_both_commit_paths() {
535 let (_t, repo, wt) = scratch().await;
536 std::fs::write(repo.join("secret"), "s\n").unwrap();
537 prepare(&cfg(vec![copy("secret")]), &repo, &wt)
538 .await
539 .unwrap();
540
541 git::git(&wt, &["add", "-A"]).await.unwrap();
543 assert!(
544 git::git(&wt, &["diff", "--cached", "--name-only"])
545 .await
546 .unwrap()
547 .is_empty()
548 );
549
550 assert!(!git::commit_all(&wt, "rescue").await.unwrap());
552 let r = git::rescue_commit(&wt, "rescue").await.unwrap();
553 assert!(!r.committed);
554
555 std::fs::write(wt.join("work.txt"), "w\n").unwrap();
557 assert!(git::commit_all(&wt, "rescue").await.unwrap());
558 let files = git::git(&wt, &["show", "--name-only", "--format=", "HEAD"])
559 .await
560 .unwrap();
561 assert_eq!(files.trim(), "work.txt");
562 }
563
564 #[tokio::test]
565 async fn products_without_worktree_config_fail_loudly_and_leave_shared_config_alone() {
566 let (_t, repo, wt) = scratch().await;
567 git::release_worktree_config(&repo).await.unwrap();
568 std::fs::write(repo.join("secret"), "s\n").unwrap();
569 let err = format!(
570 "{:#}",
571 prepare(&cfg(vec![copy("secret")]), &repo, &wt)
572 .await
573 .unwrap_err()
574 );
575 assert!(err.contains("worktreeConfig"), "{err}");
576 let shared = git::git_raw(&repo, &["config", "--get", "core.excludesFile"])
577 .await
578 .unwrap();
579 assert!(!shared.ok(), "shared config was written: {}", shared.stdout);
580 }
581
582 #[tokio::test]
583 async fn an_ignored_copy_is_still_withheld_from_commits() {
584 let (_t, repo, wt) = scratch().await;
585 std::fs::write(wt.join(".gitignore"), ".env\n").unwrap();
586 git::git(&wt, &["add", ".gitignore"]).await.unwrap();
587 git::git(&wt, &["commit", "-m", "ignore"]).await.unwrap();
588 std::fs::write(repo.join(".env"), "s\n").unwrap();
589 prepare(&cfg(vec![copy(".env")]), &repo, &wt).await.unwrap();
590 assert_eq!(withheld_paths(&wt).await, [".env"]);
591 git::git(&wt, &["add", "-f", ".env"]).await.unwrap();
593 std::fs::write(wt.join("work.txt"), "w\n").unwrap();
594 assert!(git::commit_all(&wt, "rescue").await.unwrap());
595 let files = git::git(&wt, &["show", "--name-only", "--format=", "HEAD"])
596 .await
597 .unwrap();
598 assert_eq!(files.trim(), "work.txt");
599 }
600
601 #[test]
602 fn destinations_must_stay_inside() {
603 for bad in ["/etc/x", "../x", "a/../../x", ".git/hooks/x", "C:/x", ""] {
604 assert!(valid_destination(bad).is_err(), "{bad}");
605 }
606 assert!(valid_destination("a/b.env").is_ok());
607 }
608}