1use crate::{Error, Result};
8use ostraka_core::identity::ActorId;
9use std::path::{Path, PathBuf};
10use std::process::Command;
11
12#[derive(Debug, Clone)]
13pub struct Worktree {
14 path: PathBuf,
15 branch: String,
16}
17
18impl Worktree {
19 pub fn path(&self) -> &Path {
20 &self.path
21 }
22
23 pub fn branch(&self) -> &str {
24 &self.branch
25 }
26}
27
28pub fn is_repository(dir: &Path) -> bool {
36 Command::new("git")
37 .args(["rev-parse", "--git-dir"])
38 .current_dir(dir)
39 .stdout(std::process::Stdio::null())
40 .stderr(std::process::Stdio::null())
41 .status()
42 .is_ok_and(|status| status.success())
43}
44
45pub fn has_a_commit(repo: &Path) -> bool {
52 Command::new("git")
53 .args(["rev-parse", "--verify", "HEAD"])
54 .current_dir(repo)
55 .stdout(std::process::Stdio::null())
56 .stderr(std::process::Stdio::null())
57 .status()
58 .is_ok_and(|status| status.success())
59}
60
61pub fn create(repo: &Path, base: &Path, run_id: &str, base_ref: &str) -> Result<Worktree> {
62 let path = base.join(run_id);
63 let branch = format!("ostraka/{run_id}");
64
65 let out = Command::new("git")
66 .args(["worktree", "add", "-b", &branch])
67 .arg(&path)
68 .arg(base_ref)
69 .current_dir(repo)
70 .output()?;
71
72 if !out.status.success() {
73 return Err(Error::Other(format!(
74 "git worktree add failed: {}",
75 String::from_utf8_lossy(&out.stderr).trim()
76 )));
77 }
78 Ok(Worktree { path, branch })
79}
80
81fn exclude(worktree: &Path, name: &str) {
94 let Ok(out) = Command::new("git")
95 .args(["rev-parse", "--git-path", "info/exclude"])
96 .current_dir(worktree)
97 .output()
98 else {
99 return;
100 };
101 if !out.status.success() {
102 return;
103 }
104 let path = String::from_utf8_lossy(&out.stdout).trim().to_string();
105 if path.is_empty() {
106 return;
107 }
108 let path = worktree.join(path);
109 if let Some(parent) = path.parent() {
110 let _ = std::fs::create_dir_all(parent);
111 }
112 let existing = std::fs::read_to_string(&path).unwrap_or_default();
113 if existing.lines().any(|line| line.trim() == name) {
114 return;
115 }
116 use std::io::Write;
117 if let Ok(mut file) = std::fs::OpenOptions::new()
118 .create(true)
119 .append(true)
120 .open(&path)
121 {
122 let _ = writeln!(file, "{name}");
123 }
124}
125
126#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct SetupProblem {
133 pub step: String,
134 pub reason: String,
135}
136
137pub fn notes_linked(worktree: &Path) -> bool {
157 linked(worktree, "notes")
158}
159
160pub fn linked(worktree: &Path, name: &str) -> bool {
166 let path = worktree.join(name);
167 let Ok(meta) = std::fs::symlink_metadata(&path) else {
168 return false;
169 };
170 if !meta.file_type().is_symlink() {
171 return false;
172 }
173 let (Ok(root), Ok(resolved)) = (worktree.canonicalize(), path.canonicalize()) else {
183 return false;
184 };
185 !resolved.starts_with(&root)
186}
187
188pub fn prepare(
189 project: &Path,
190 worktree: &Path,
191 config: &ostraka_core::config::WorktreeConfig,
192 notes: Option<&Path>,
193 skills: Option<&Path>,
194 ceiling: Option<std::time::Duration>,
195) -> std::result::Result<Vec<String>, SetupProblem> {
196 let mut done = Vec::new();
197
198 let linked: Vec<(String, PathBuf)> = notes
204 .map(|path| ("notes".to_string(), path.to_path_buf()))
205 .into_iter()
206 .chain(skills.map(|path| ("skills".to_string(), path.to_path_buf())))
207 .chain(config.link.iter().map(|n| (n.clone(), project.join(n))))
208 .collect();
209
210 for (name, source) in linked {
211 let name = &name;
212 let target = worktree.join(name);
213 if !source.exists() {
214 return Err(SetupProblem {
217 step: format!("link {name}"),
218 reason: format!(
219 "{} is declared in [worktree] link and is not there; the worktree cannot be \
220 prepared without it",
221 source.display()
222 ),
223 });
224 }
225 if target.exists() || std::fs::symlink_metadata(&target).is_ok() {
228 continue;
229 }
230 if let Some(parent) = target.parent() {
231 let _ = std::fs::create_dir_all(parent);
232 }
233 let source = source.canonicalize().unwrap_or(source);
236 if let Err(e) = symlink(&source, &target) {
237 return Err(SetupProblem {
238 step: format!("link {name}"),
239 reason: format!("could not link {} into the worktree: {e}", source.display()),
240 });
241 }
242 exclude(worktree, name);
243 done.push(format!("link {name}"));
244 }
245
246 if let Some(command) = config.setup.as_deref().filter(|c| !c.trim().is_empty()) {
247 let record = crate::gate::run_command(command, worktree, ceiling);
248 if record.exit_code != Some(0) {
249 let tail: Vec<&str> = record
250 .stderr
251 .lines()
252 .rev()
253 .take(6)
254 .collect::<Vec<_>>()
255 .into_iter()
256 .rev()
257 .collect();
258 return Err(SetupProblem {
259 step: "setup".to_string(),
260 reason: format!(
261 "`{command}` exited with {}: {}",
262 record
263 .exit_code
264 .map(|c| c.to_string())
265 .unwrap_or_else(|| "no exit code".to_string()),
266 if tail.is_empty() {
267 "and said nothing".to_string()
268 } else {
269 tail.join(" / ")
270 }
271 ),
272 });
273 }
274 done.push(format!("setup `{command}`"));
275 }
276
277 Ok(done)
278}
279
280#[cfg(unix)]
281fn symlink(source: &Path, target: &Path) -> std::io::Result<()> {
282 std::os::unix::fs::symlink(source, target)
283}
284
285#[cfg(windows)]
286fn symlink(source: &Path, target: &Path) -> std::io::Result<()> {
287 if source.is_dir() {
288 std::os::windows::fs::symlink_dir(source, target)
289 } else {
290 std::os::windows::fs::symlink_file(source, target)
291 }
292}
293
294pub fn touched_paths(worktree: &Path) -> Result<Vec<String>> {
299 let out = Command::new("git")
300 .args(["status", "--porcelain"])
301 .current_dir(worktree)
302 .output()?;
303
304 if !out.status.success() {
305 return Err(Error::Other(format!(
306 "git status failed: {}",
307 String::from_utf8_lossy(&out.stderr).trim()
308 )));
309 }
310
311 Ok(String::from_utf8_lossy(&out.stdout)
312 .lines()
313 .filter_map(|line| {
314 line.get(3..).map(|p| p.trim().to_string())
316 })
317 .filter(|p| !p.is_empty())
318 .collect())
319}
320
321pub fn diff(worktree: &Path) -> Result<String> {
326 let add = Command::new("git")
329 .args(["add", "-A"])
330 .current_dir(worktree)
331 .output()?;
332 if !add.status.success() {
333 return Err(Error::Other(format!(
334 "git add failed: {}",
335 String::from_utf8_lossy(&add.stderr).trim()
336 )));
337 }
338
339 let out = Command::new("git")
340 .args(["diff", "--cached"])
341 .current_dir(worktree)
342 .output()?;
343 if !out.status.success() {
344 return Err(Error::Other(format!(
345 "git diff failed: {}",
346 String::from_utf8_lossy(&out.stderr).trim()
347 )));
348 }
349 Ok(String::from_utf8_lossy(&out.stdout).into_owned())
350}
351
352pub fn commit(worktree: &Path, message: &str, author: &ActorId) -> Result<()> {
364 let out = Command::new("git")
365 .arg("-c")
366 .arg(format!("user.name={author}"))
367 .arg("-c")
368 .arg(format!(
371 "user.email={}@ostraka.invalid",
372 email_local(author)
373 ))
374 .args(["commit", "-m", message])
375 .current_dir(worktree)
376 .output()?;
377 if !out.status.success() {
378 return Err(Error::Other(format!(
379 "git commit failed: {}",
380 String::from_utf8_lossy(&out.stderr).trim()
381 )));
382 }
383 Ok(())
384}
385
386fn email_local(author: &ActorId) -> String {
391 let cleaned: String = author
392 .as_str()
393 .chars()
394 .map(|c| {
395 if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' {
396 c
397 } else {
398 '-'
399 }
400 })
401 .collect();
402 if cleaned.is_empty() {
403 "agent".to_string()
404 } else {
405 cleaned
406 }
407}
408
409pub fn release(repo: &Path, wt: &Worktree) -> Result<()> {
415 remove_checkout(repo, &wt.path)
416}
417
418pub fn release_path(repo: &Path, path: &Path) -> Result<()> {
420 remove_checkout(repo, path)
421}
422
423fn remove_checkout(repo: &Path, path: &Path) -> Result<()> {
424 let out = Command::new("git")
425 .args(["worktree", "remove", "--force"])
426 .arg(path)
427 .current_dir(repo)
428 .output()?;
429
430 if !out.status.success() {
431 return Err(Error::Other(format!(
432 "git worktree remove failed: {}",
433 String::from_utf8_lossy(&out.stderr).trim()
434 )));
435 }
436 Ok(())
437}
438
439pub fn list(repo: &Path, base: &Path) -> Result<Vec<PathBuf>> {
441 if !base.is_dir() {
442 return Ok(Vec::new());
443 }
444 let _ = repo;
445 let mut found: Vec<PathBuf> = std::fs::read_dir(base)?
446 .filter_map(|e| e.ok().map(|e| e.path()))
447 .filter(|p| p.is_dir())
448 .collect();
449 found.sort();
450 Ok(found)
451}
452
453#[cfg(test)]
454mod tests {
455 use super::*;
456 use ostraka_core::config::WorktreeConfig;
457
458 fn scratch(name: &str) -> PathBuf {
459 let path = std::env::temp_dir().join(format!("ostraka-prep-{}-{name}", std::process::id()));
460 let _ = std::fs::remove_dir_all(&path);
461 std::fs::create_dir_all(path.join("project")).expect("project");
462 std::fs::create_dir_all(path.join("wt")).expect("worktree");
463 path
464 }
465
466 fn prep_config(link: &[&str], setup: Option<&str>) -> WorktreeConfig {
467 WorktreeConfig {
468 base: "worktrees".into(),
469 link: link.iter().map(|s| (*s).to_string()).collect(),
470 setup: setup.map(str::to_string),
471 }
472 }
473
474 #[test]
475 fn what_git_ignores_is_linked_into_the_checkout() {
476 let dir = scratch("link");
480 std::fs::create_dir_all(dir.join("project/node_modules")).expect("deps");
481 std::fs::write(dir.join("project/node_modules/marker"), "here").expect("write");
482
483 let done = prepare(
484 &dir.join("project"),
485 &dir.join("wt"),
486 &prep_config(&["node_modules"], None),
487 None,
488 None,
489 None,
490 )
491 .expect("prepares");
492
493 assert_eq!(done, ["link node_modules"]);
494 assert_eq!(
495 std::fs::read_to_string(dir.join("wt/node_modules/marker")).expect("reads"),
496 "here"
497 );
498 let _ = std::fs::remove_dir_all(&dir);
499 }
500
501 #[test]
502 fn the_workspaces_notes_reach_every_worktree_without_being_configured() {
503 let dir = scratch("notes");
507 std::fs::create_dir_all(dir.join("notes")).expect("notes");
508 std::fs::create_dir_all(dir.join("project")).expect("project");
509 std::fs::write(dir.join("notes/earlier.md"), "what was worked out").expect("write");
510
511 let done = prepare(
512 &dir.join("project"),
513 &dir.join("wt"),
514 &prep_config(&[], None),
515 Some(&dir.join("notes")),
516 None,
517 None,
518 )
519 .expect("prepares");
520
521 assert_eq!(done, ["link notes"]);
522 assert_eq!(
523 std::fs::read_to_string(dir.join("wt/notes/earlier.md")).expect("reads"),
524 "what was worked out"
525 );
526
527 std::fs::write(dir.join("wt/notes/during.md"), "what was learned").expect("write");
530 assert!(
531 dir.join("notes/during.md").is_file(),
532 "the note stayed in the worktree"
533 );
534 let _ = std::fs::remove_dir_all(&dir);
535 }
536
537 #[test]
538 fn a_worktree_without_notes_is_prepared_anyway() {
539 let dir = scratch("no-notes");
541 std::fs::create_dir_all(dir.join("project")).expect("project");
542 let done = prepare(
543 &dir.join("project"),
544 &dir.join("wt"),
545 &prep_config(&[], None),
546 None,
547 None,
548 None,
549 )
550 .expect("prepares");
551 assert!(done.is_empty());
552 let _ = std::fs::remove_dir_all(&dir);
553 }
554
555 #[test]
556 fn the_link_is_absolute_so_the_worktree_depth_does_not_matter() {
557 let dir = scratch("absolute");
559 std::fs::create_dir_all(dir.join("project/node_modules")).expect("deps");
560 std::fs::create_dir_all(dir.join("wt/deep/deeper")).expect("deep");
561 prepare(
562 &dir.join("project"),
563 &dir.join("wt/deep/deeper"),
564 &prep_config(&["node_modules"], None),
565 None,
566 None,
567 None,
568 )
569 .expect("prepares");
570 let link = std::fs::read_link(dir.join("wt/deep/deeper/node_modules")).expect("a link");
571 assert!(link.is_absolute(), "{link:?}");
572 let _ = std::fs::remove_dir_all(&dir);
573 }
574
575 #[test]
576 fn a_declared_link_that_is_absent_is_said_plainly() {
577 let dir = scratch("missing");
578 let problem = prepare(
579 &dir.join("project"),
580 &dir.join("wt"),
581 &prep_config(&["node_modules"], None),
582 None,
583 None,
584 None,
585 )
586 .expect_err("must refuse");
587 assert_eq!(problem.step, "link node_modules");
588 assert!(problem.reason.contains("is not there"), "{problem:?}");
589 let _ = std::fs::remove_dir_all(&dir);
590 }
591
592 #[test]
593 fn something_the_repository_tracks_is_not_replaced_by_a_link() {
594 let dir = scratch("tracked");
595 std::fs::create_dir_all(dir.join("project/vendor")).expect("source");
596 std::fs::create_dir_all(dir.join("wt/vendor")).expect("checked out");
597 std::fs::write(dir.join("wt/vendor/theirs"), "tracked").expect("write");
598
599 prepare(
600 &dir.join("project"),
601 &dir.join("wt"),
602 &prep_config(&["vendor"], None),
603 None,
604 None,
605 None,
606 )
607 .expect("prepares");
608 assert!(
609 dir.join("wt/vendor/theirs").is_file(),
610 "the checkout lost a tracked file"
611 );
612 let _ = std::fs::remove_dir_all(&dir);
613 }
614
615 #[test]
616 fn a_setup_command_that_fails_reports_the_environment_not_the_change() {
617 let dir = scratch("setup-fails");
618 let problem = prepare(
619 &dir.join("project"),
620 &dir.join("wt"),
621 &prep_config(&[], Some("echo no registry >&2; exit 1")),
622 None,
623 None,
624 None,
625 )
626 .expect_err("must refuse");
627 assert_eq!(problem.step, "setup");
628 assert!(problem.reason.contains("no registry"), "{problem:?}");
629 let _ = std::fs::remove_dir_all(&dir);
630 }
631
632 #[test]
633 fn a_setup_command_runs_inside_the_worktree() {
634 let dir = scratch("setup-cwd");
635 prepare(
636 &dir.join("project"),
637 &dir.join("wt"),
638 &prep_config(&[], Some("pwd > where")),
639 None,
640 None,
641 None,
642 )
643 .expect("prepares");
644 let ran_in = std::fs::read_to_string(dir.join("wt/where")).expect("reads");
645 assert!(ran_in.trim().ends_with("wt"), "{ran_in}");
646 let _ = std::fs::remove_dir_all(&dir);
647 }
648
649 #[test]
650 fn nothing_declared_means_nothing_done() {
651 let dir = scratch("nothing");
652 let done = prepare(
653 &dir.join("project"),
654 &dir.join("wt"),
655 &prep_config(&[], None),
656 None,
657 None,
658 None,
659 )
660 .expect("prepares");
661 assert!(done.is_empty());
662 let _ = std::fs::remove_dir_all(&dir);
663 }
664
665 #[test]
666 fn an_identity_with_spaces_still_yields_a_usable_address() {
667 assert_eq!(email_local(&ActorId::new("agent archon")), "agent-archon");
668 assert_eq!(email_local(&ActorId::new("archon")), "archon");
669 }
670
671 #[test]
672 fn an_empty_identity_falls_back_rather_than_producing_an_at_sign_alone() {
673 assert_eq!(email_local(&ActorId::new("")), "agent");
674 }
675}
676
677#[cfg(test)]
678mod linked_tests {
679 use super::linked;
680
681 fn scratch(name: &str) -> std::path::PathBuf {
682 let dir =
683 std::env::temp_dir().join(format!("ostraka-linked-{}-{name}", std::process::id()));
684 let _ = std::fs::remove_dir_all(&dir);
685 std::fs::create_dir_all(dir.join("wt")).expect("worktree");
686 dir
687 }
688
689 #[cfg(unix)]
690 #[test]
691 fn a_relative_link_that_stays_inside_the_checkout_is_not_the_workspaces() {
692 let dir = scratch("relative-inside");
697 std::fs::create_dir_all(dir.join("wt/sub")).expect("sub");
698 std::os::unix::fs::symlink("sub", dir.join("wt/notes")).expect("link");
699 assert!(!linked(&dir.join("wt"), "notes"));
700 let _ = std::fs::remove_dir_all(&dir);
701 }
702
703 #[cfg(unix)]
704 #[test]
705 fn a_link_out_of_the_checkout_is_the_workspaces_however_it_is_written() {
706 let dir = scratch("outside");
707 std::fs::create_dir_all(dir.join("shared")).expect("shared");
708 std::os::unix::fs::symlink(dir.join("shared"), dir.join("wt/notes")).expect("absolute");
709 std::os::unix::fs::symlink("../shared", dir.join("wt/skills")).expect("relative");
710 assert!(linked(&dir.join("wt"), "notes"), "absolute target");
711 assert!(linked(&dir.join("wt"), "skills"), "relative target");
712 let _ = std::fs::remove_dir_all(&dir);
713 }
714
715 #[cfg(unix)]
716 #[test]
717 fn a_link_to_nothing_is_not_a_directory_anybody_can_be_told_about() {
718 let dir = scratch("broken");
719 std::os::unix::fs::symlink("../never-existed", dir.join("wt/notes")).expect("link");
720 assert!(!linked(&dir.join("wt"), "notes"));
721 let _ = std::fs::remove_dir_all(&dir);
722 }
723
724 #[test]
725 fn a_real_directory_is_the_repositorys_own() {
726 let dir = scratch("real");
727 std::fs::create_dir_all(dir.join("wt/notes")).expect("notes");
728 assert!(!linked(&dir.join("wt"), "notes"));
729 assert!(!linked(&dir.join("wt"), "absent"));
730 let _ = std::fs::remove_dir_all(&dir);
731 }
732}