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 tree(worktree: &Path) -> Result<String> {
327 let out = Command::new("git")
328 .args(["write-tree"])
329 .current_dir(worktree)
330 .output()?;
331 if !out.status.success() {
332 return Err(Error::Other(format!(
333 "git write-tree failed: {}",
334 String::from_utf8_lossy(&out.stderr).trim()
335 )));
336 }
337 Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
338}
339
340pub fn restage(worktree: &Path) -> Result<String> {
347 let add = Command::new("git")
348 .args(["add", "-A"])
349 .current_dir(worktree)
350 .output()?;
351 if !add.status.success() {
352 return Err(Error::Other(format!(
353 "git add failed: {}",
354 String::from_utf8_lossy(&add.stderr).trim()
355 )));
356 }
357 tree(worktree)
358}
359
360pub fn staged_paths(worktree: &Path) -> Result<Vec<String>> {
367 let out = Command::new("git")
368 .args(["diff", "--cached", "--name-only", "--no-renames", "-z"])
369 .current_dir(worktree)
370 .output()?;
371 if !out.status.success() {
372 return Err(Error::Other(format!(
373 "git diff failed: {}",
374 String::from_utf8_lossy(&out.stderr).trim()
375 )));
376 }
377 Ok(String::from_utf8_lossy(&out.stdout)
378 .split('\0')
379 .filter(|p| !p.is_empty())
380 .map(str::to_string)
381 .collect())
382}
383
384pub fn diff(worktree: &Path) -> Result<String> {
389 let add = Command::new("git")
392 .args(["add", "-A"])
393 .current_dir(worktree)
394 .output()?;
395 if !add.status.success() {
396 return Err(Error::Other(format!(
397 "git add failed: {}",
398 String::from_utf8_lossy(&add.stderr).trim()
399 )));
400 }
401
402 let out = Command::new("git")
403 .args(["diff", "--cached"])
404 .current_dir(worktree)
405 .output()?;
406 if !out.status.success() {
407 return Err(Error::Other(format!(
408 "git diff failed: {}",
409 String::from_utf8_lossy(&out.stderr).trim()
410 )));
411 }
412 Ok(String::from_utf8_lossy(&out.stdout).into_owned())
413}
414
415pub fn commit(worktree: &Path, message: &str, author: &ActorId) -> Result<()> {
427 let out = Command::new("git")
428 .arg("-c")
429 .arg(format!("user.name={author}"))
430 .arg("-c")
431 .arg(format!(
434 "user.email={}@ostraka.invalid",
435 email_local(author)
436 ))
437 .args(["commit", "-m", message])
438 .current_dir(worktree)
439 .output()?;
440 if !out.status.success() {
441 return Err(Error::Other(format!(
442 "git commit failed: {}",
443 String::from_utf8_lossy(&out.stderr).trim()
444 )));
445 }
446 Ok(())
447}
448
449fn email_local(author: &ActorId) -> String {
454 let cleaned: String = author
455 .as_str()
456 .chars()
457 .map(|c| {
458 if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' {
459 c
460 } else {
461 '-'
462 }
463 })
464 .collect();
465 if cleaned.is_empty() {
466 "agent".to_string()
467 } else {
468 cleaned
469 }
470}
471
472pub fn release(repo: &Path, wt: &Worktree) -> Result<()> {
478 remove_checkout(repo, &wt.path)
479}
480
481pub fn release_path(repo: &Path, path: &Path) -> Result<()> {
483 remove_checkout(repo, path)
484}
485
486fn remove_checkout(repo: &Path, path: &Path) -> Result<()> {
487 let out = Command::new("git")
488 .args(["worktree", "remove", "--force"])
489 .arg(path)
490 .current_dir(repo)
491 .output()?;
492
493 if !out.status.success() {
494 return Err(Error::Other(format!(
495 "git worktree remove failed: {}",
496 String::from_utf8_lossy(&out.stderr).trim()
497 )));
498 }
499 Ok(())
500}
501
502pub fn list(repo: &Path, base: &Path) -> Result<Vec<PathBuf>> {
504 if !base.is_dir() {
505 return Ok(Vec::new());
506 }
507 let _ = repo;
508 let mut found: Vec<PathBuf> = std::fs::read_dir(base)?
509 .filter_map(|e| e.ok().map(|e| e.path()))
510 .filter(|p| p.is_dir())
511 .collect();
512 found.sort();
513 Ok(found)
514}
515
516#[cfg(test)]
517mod tests {
518 use super::*;
519 use ostraka_core::config::WorktreeConfig;
520
521 fn scratch(name: &str) -> PathBuf {
522 let path = std::env::temp_dir().join(format!("ostraka-prep-{}-{name}", std::process::id()));
523 let _ = std::fs::remove_dir_all(&path);
524 std::fs::create_dir_all(path.join("project")).expect("project");
525 std::fs::create_dir_all(path.join("wt")).expect("worktree");
526 path
527 }
528
529 fn prep_config(link: &[&str], setup: Option<&str>) -> WorktreeConfig {
530 WorktreeConfig {
531 base: "worktrees".into(),
532 link: link.iter().map(|s| (*s).to_string()).collect(),
533 setup: setup.map(str::to_string),
534 }
535 }
536
537 #[test]
538 fn what_git_ignores_is_linked_into_the_checkout() {
539 let dir = scratch("link");
543 std::fs::create_dir_all(dir.join("project/node_modules")).expect("deps");
544 std::fs::write(dir.join("project/node_modules/marker"), "here").expect("write");
545
546 let done = prepare(
547 &dir.join("project"),
548 &dir.join("wt"),
549 &prep_config(&["node_modules"], None),
550 None,
551 None,
552 None,
553 )
554 .expect("prepares");
555
556 assert_eq!(done, ["link node_modules"]);
557 assert_eq!(
558 std::fs::read_to_string(dir.join("wt/node_modules/marker")).expect("reads"),
559 "here"
560 );
561 let _ = std::fs::remove_dir_all(&dir);
562 }
563
564 #[test]
565 fn the_workspaces_notes_reach_every_worktree_without_being_configured() {
566 let dir = scratch("notes");
570 std::fs::create_dir_all(dir.join("notes")).expect("notes");
571 std::fs::create_dir_all(dir.join("project")).expect("project");
572 std::fs::write(dir.join("notes/earlier.md"), "what was worked out").expect("write");
573
574 let done = prepare(
575 &dir.join("project"),
576 &dir.join("wt"),
577 &prep_config(&[], None),
578 Some(&dir.join("notes")),
579 None,
580 None,
581 )
582 .expect("prepares");
583
584 assert_eq!(done, ["link notes"]);
585 assert_eq!(
586 std::fs::read_to_string(dir.join("wt/notes/earlier.md")).expect("reads"),
587 "what was worked out"
588 );
589
590 std::fs::write(dir.join("wt/notes/during.md"), "what was learned").expect("write");
593 assert!(
594 dir.join("notes/during.md").is_file(),
595 "the note stayed in the worktree"
596 );
597 let _ = std::fs::remove_dir_all(&dir);
598 }
599
600 #[test]
601 fn a_worktree_without_notes_is_prepared_anyway() {
602 let dir = scratch("no-notes");
604 std::fs::create_dir_all(dir.join("project")).expect("project");
605 let done = prepare(
606 &dir.join("project"),
607 &dir.join("wt"),
608 &prep_config(&[], None),
609 None,
610 None,
611 None,
612 )
613 .expect("prepares");
614 assert!(done.is_empty());
615 let _ = std::fs::remove_dir_all(&dir);
616 }
617
618 #[test]
619 fn the_link_is_absolute_so_the_worktree_depth_does_not_matter() {
620 let dir = scratch("absolute");
622 std::fs::create_dir_all(dir.join("project/node_modules")).expect("deps");
623 std::fs::create_dir_all(dir.join("wt/deep/deeper")).expect("deep");
624 prepare(
625 &dir.join("project"),
626 &dir.join("wt/deep/deeper"),
627 &prep_config(&["node_modules"], None),
628 None,
629 None,
630 None,
631 )
632 .expect("prepares");
633 let link = std::fs::read_link(dir.join("wt/deep/deeper/node_modules")).expect("a link");
634 assert!(link.is_absolute(), "{link:?}");
635 let _ = std::fs::remove_dir_all(&dir);
636 }
637
638 #[test]
639 fn a_declared_link_that_is_absent_is_said_plainly() {
640 let dir = scratch("missing");
641 let problem = prepare(
642 &dir.join("project"),
643 &dir.join("wt"),
644 &prep_config(&["node_modules"], None),
645 None,
646 None,
647 None,
648 )
649 .expect_err("must refuse");
650 assert_eq!(problem.step, "link node_modules");
651 assert!(problem.reason.contains("is not there"), "{problem:?}");
652 let _ = std::fs::remove_dir_all(&dir);
653 }
654
655 #[test]
656 fn something_the_repository_tracks_is_not_replaced_by_a_link() {
657 let dir = scratch("tracked");
658 std::fs::create_dir_all(dir.join("project/vendor")).expect("source");
659 std::fs::create_dir_all(dir.join("wt/vendor")).expect("checked out");
660 std::fs::write(dir.join("wt/vendor/theirs"), "tracked").expect("write");
661
662 prepare(
663 &dir.join("project"),
664 &dir.join("wt"),
665 &prep_config(&["vendor"], None),
666 None,
667 None,
668 None,
669 )
670 .expect("prepares");
671 assert!(
672 dir.join("wt/vendor/theirs").is_file(),
673 "the checkout lost a tracked file"
674 );
675 let _ = std::fs::remove_dir_all(&dir);
676 }
677
678 #[test]
679 fn a_setup_command_that_fails_reports_the_environment_not_the_change() {
680 let dir = scratch("setup-fails");
681 let problem = prepare(
682 &dir.join("project"),
683 &dir.join("wt"),
684 &prep_config(&[], Some("echo no registry >&2; exit 1")),
685 None,
686 None,
687 None,
688 )
689 .expect_err("must refuse");
690 assert_eq!(problem.step, "setup");
691 assert!(problem.reason.contains("no registry"), "{problem:?}");
692 let _ = std::fs::remove_dir_all(&dir);
693 }
694
695 #[test]
696 fn a_setup_command_runs_inside_the_worktree() {
697 let dir = scratch("setup-cwd");
698 prepare(
699 &dir.join("project"),
700 &dir.join("wt"),
701 &prep_config(&[], Some("pwd > where")),
702 None,
703 None,
704 None,
705 )
706 .expect("prepares");
707 let ran_in = std::fs::read_to_string(dir.join("wt/where")).expect("reads");
708 assert!(ran_in.trim().ends_with("wt"), "{ran_in}");
709 let _ = std::fs::remove_dir_all(&dir);
710 }
711
712 #[test]
713 fn nothing_declared_means_nothing_done() {
714 let dir = scratch("nothing");
715 let done = prepare(
716 &dir.join("project"),
717 &dir.join("wt"),
718 &prep_config(&[], None),
719 None,
720 None,
721 None,
722 )
723 .expect("prepares");
724 assert!(done.is_empty());
725 let _ = std::fs::remove_dir_all(&dir);
726 }
727
728 #[test]
729 fn an_identity_with_spaces_still_yields_a_usable_address() {
730 assert_eq!(email_local(&ActorId::new("agent archon")), "agent-archon");
731 assert_eq!(email_local(&ActorId::new("archon")), "archon");
732 }
733
734 #[test]
735 fn an_empty_identity_falls_back_rather_than_producing_an_at_sign_alone() {
736 assert_eq!(email_local(&ActorId::new("")), "agent");
737 }
738}
739
740#[cfg(test)]
741mod linked_tests {
742 use super::linked;
743
744 fn scratch(name: &str) -> std::path::PathBuf {
745 let dir =
746 std::env::temp_dir().join(format!("ostraka-linked-{}-{name}", std::process::id()));
747 let _ = std::fs::remove_dir_all(&dir);
748 std::fs::create_dir_all(dir.join("wt")).expect("worktree");
749 dir
750 }
751
752 #[cfg(unix)]
753 #[test]
754 fn a_relative_link_that_stays_inside_the_checkout_is_not_the_workspaces() {
755 let dir = scratch("relative-inside");
760 std::fs::create_dir_all(dir.join("wt/sub")).expect("sub");
761 std::os::unix::fs::symlink("sub", dir.join("wt/notes")).expect("link");
762 assert!(!linked(&dir.join("wt"), "notes"));
763 let _ = std::fs::remove_dir_all(&dir);
764 }
765
766 #[cfg(unix)]
767 #[test]
768 fn a_link_out_of_the_checkout_is_the_workspaces_however_it_is_written() {
769 let dir = scratch("outside");
770 std::fs::create_dir_all(dir.join("shared")).expect("shared");
771 std::os::unix::fs::symlink(dir.join("shared"), dir.join("wt/notes")).expect("absolute");
772 std::os::unix::fs::symlink("../shared", dir.join("wt/skills")).expect("relative");
773 assert!(linked(&dir.join("wt"), "notes"), "absolute target");
774 assert!(linked(&dir.join("wt"), "skills"), "relative target");
775 let _ = std::fs::remove_dir_all(&dir);
776 }
777
778 #[cfg(unix)]
779 #[test]
780 fn a_link_to_nothing_is_not_a_directory_anybody_can_be_told_about() {
781 let dir = scratch("broken");
782 std::os::unix::fs::symlink("../never-existed", dir.join("wt/notes")).expect("link");
783 assert!(!linked(&dir.join("wt"), "notes"));
784 let _ = std::fs::remove_dir_all(&dir);
785 }
786
787 #[test]
788 fn a_real_directory_is_the_repositorys_own() {
789 let dir = scratch("real");
790 std::fs::create_dir_all(dir.join("wt/notes")).expect("notes");
791 assert!(!linked(&dir.join("wt"), "notes"));
792 assert!(!linked(&dir.join("wt"), "absent"));
793 let _ = std::fs::remove_dir_all(&dir);
794 }
795}