Skip to main content

ostraka_runtime/
worktree.rs

1//! Isolated checkouts, one per task.
2//!
3//! Work happens in a worktree so that a change is a diff on disk before it is
4//! anything else — reviewable by an agent that did not write it, and discardable
5//! without touching the branch anyone else is on.
6
7use 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
28/// Creates a worktree for a run, branching from `base_ref`.
29/// Whether this directory is somewhere git can make a worktree.
30///
31/// The whole runtime stands on `git worktree add`, so a directory that is not
32/// a repository cannot run anything — and used to say so for the first time
33/// two minutes into a run, in git's own words, after a vendor had been paid.
34/// Asked once, cheaply, by whoever is about to promise that a run will work.
35pub 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
45/// Whether this repository has a commit to branch from.
46///
47/// `git worktree add <path> HEAD` on a repository nobody has committed to
48/// fails with `invalid reference: HEAD`, which is the second half of the same
49/// problem `is_repository` catches the first half of: `git init` alone is not
50/// enough to run in.
51pub 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
81/// Keeps a linked name out of git's sight, in this worktree only.
82///
83/// A link is not part of the change. Left visible, `git status` reports it as
84/// something the agent added, so it counts as a touched path, it is committed
85/// with the work, and a reviewer is shown a symlink nobody asked for — which
86/// is what happened the first time a real vendor ran against a workspace with
87/// notes in it.
88///
89/// Written to the worktree's own exclude file rather than to a `.gitignore`:
90/// that file belongs to the repository and is not this to edit. A failure here
91/// is not worth failing the run over — the worst case is the link showing up
92/// in a diff, which is where this started.
93fn 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/// What went wrong getting a worktree ready to work in.
127///
128/// Separate from a gate failure on purpose. "The environment was not ready" and
129/// "the change was rejected" are different answers, and reporting the first as
130/// the second is what made a missing `node_modules` read as a refused change.
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct SetupProblem {
133    pub step: String,
134    pub reason: String,
135}
136
137/// Makes a fresh checkout usable: links what git ignores, runs what the project
138/// says it needs.
139///
140/// Runs before the agent, not merely before the gate. An agent that cannot run
141/// the project's own tools cannot see what it broke, which is how a run ends
142/// with the agent having changed nothing and nobody knowing why.
143/// Whether the workspace's notes reached this worktree as a link.
144///
145/// The fact the author prompt turns on, and it cannot be read off the
146/// configuration. `[worktree]` naming notes is an intention; a repository that
147/// tracks its own `notes/` keeps it, because [`prepare`] leaves what the
148/// checkout brought rather than replacing it. Asking the config would then
149/// tell an agent that a directory the repository owns is not part of the
150/// repository, and invite it to write into the diff it is about to be judged
151/// on.
152///
153/// A symlink is not enough on its own: a repository may track one under that
154/// name. The property the sentence rests on is that it points *out* of the
155/// checkout, so that is what is checked.
156pub fn notes_linked(worktree: &Path) -> bool {
157    linked(worktree, "notes")
158}
159
160/// Whether one of the workspace's directories reached this worktree as a link.
161///
162/// The same question for `skills/` as for `notes/`, and the same reason for
163/// asking the worktree rather than the configuration: naming a directory in
164/// `[worktree]` is an intention, and what the checkout brought stays.
165pub 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    // Resolved, not compared as written. `read_link` hands back exactly what
174    // the link holds, so a relative target — `../shared`, or `sub/dir` — never
175    // starts with an absolute root and every one of them would read as
176    // pointing out of the checkout. The links `prepare` makes are absolute,
177    // but a repository can track a relative one under either name, and this is
178    // what decides whether an author is told the directory is not its own.
179    //
180    // Canonicalising also settles a broken link: it fails, and a link to
181    // nothing is not the workspace's directory.
182    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    // The workspace's notes, linked rather than copied and rather than
199    // configured. An agent writing here writes into the real directory, so
200    // what it worked out survives a refusal — and because the link points out
201    // of the checkout, none of it lands in the diff a reviewer judges. Notes
202    // are what was learned; the diff is what was changed.
203    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            // Said plainly rather than left to surface as an unrunnable check.
215            // Declaring it means needing it, so its absence is the answer.
216            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        // Something the repository tracks under that name already arrived with
226        // the checkout, and it is not this to replace.
227        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        // Absolute, so the link does not depend on how deep `[worktree] base`
234        // puts the checkout — the fragility a relative `../../` would carry.
235        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
294/// Lists paths modified inside a worktree.
295///
296/// Read from git, never from the agent's own account of what it did — an agent
297/// that misreports its edits should not be able to shrink its own diff.
298pub 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            // Porcelain v1: two status columns, a space, then the path.
315            line.get(3..).map(|p| p.trim().to_string())
316        })
317        .filter(|p| !p.is_empty())
318        .collect())
319}
320
321/// The tree the index holds right now, as git names it.
322///
323/// Called straight after [`diff`] has staged the change, this is the identity
324/// of exactly what a reviewer is about to be shown — and so of exactly what an
325/// approval can be allowed to commit.
326pub 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
340/// Stages whatever is in the worktree now and names the resulting tree.
341///
342/// The second half of the comparison [`tree`] starts. Staging again, rather
343/// than only reading the index, is what catches an edit nobody staged: a
344/// reviewer that changed a file without `git add` has still changed the thing
345/// it was asked to judge.
346pub 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
360/// Every path the staged change adds, removes or modifies.
361///
362/// `--no-renames` so a rename is reported as the two paths it involves rather
363/// than as an `old -> new` string no path policy can match, and `-z` so a path
364/// with a space or a quote in it is the path and not git's quoted rendering of
365/// it.
366pub 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
384/// The change a run produced, as a diff against the base ref.
385///
386/// This is what a reviewer sees. It is read from git rather than from the
387/// agent, so an agent cannot narrow its own diff by under-reporting.
388pub fn diff(worktree: &Path) -> Result<String> {
389    // Stage everything first so that new files appear in the diff at all;
390    // untracked files are invisible to `git diff` otherwise.
391    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
415/// Commits the staged change inside the worktree, as the agent that wrote it.
416///
417/// The identity is set on the command rather than read from git config, for two
418/// reasons. A change an agent wrote should not be attributed to whichever human
419/// happened to start the run — the audit trail is the product. And a run that
420/// has already done all its work should not be thrown away at the last step
421/// because the machine has no `user.email` configured, which is the ordinary
422/// state of a CI runner.
423///
424/// Committing is as far as a run goes. Merging is a separate, human-initiated
425/// act: an approved change is ready to merge, not already merged.
426pub 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        // .invalid is reserved by RFC 2606 and can never resolve, which is the
432        // point: this address identifies an agent, it does not reach anyone.
433        .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
449/// An actor id reduced to something git will accept left of the `@`.
450///
451/// Identities are free-form strings; an id containing a space or an angle
452/// bracket would produce a malformed address and a commit git refuses.
453fn 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
472/// Removes a worktree, keeping the branch it was on.
473///
474/// The distinction matters: the branch holds the commit a run produced, and
475/// promotion, replay and the diff pane all read it from there. Taking the
476/// branch would take the change.
477pub fn release(repo: &Path, wt: &Worktree) -> Result<()> {
478    remove_checkout(repo, &wt.path)
479}
480
481/// Removes a worktree by path, for one that has outlived its run.
482pub 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
502/// Every worktree this project has created, by path.
503pub 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        // The reported defect: a worktree is a fresh checkout, so node_modules
540        // is absent and every check needing the toolchain fails for a reason
541        // that has nothing to do with the change.
542        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        // What an agent works out along the way should survive the run that
567        // worked it out — including a refused one, which is the run whose
568        // notes are worth the most.
569        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        // Written through the link, so it lands in the workspace rather than
591        // in a checkout that is about to be thrown away.
592        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        // A workspace nobody has taken notes in is not a broken workspace.
603        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        // A relative `../../` breaks the moment [worktree] base changes.
621        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        // The one a comparison of unresolved targets gets wrong: `read_link`
756        // hands back `sub`, which starts with nothing absolute, so it would
757        // read as pointing out of the checkout and the author would be told a
758        // directory the repository owns is not part of the repository.
759        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}