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 change a run produced, as a diff against the base ref.
322///
323/// This is what a reviewer sees. It is read from git rather than from the
324/// agent, so an agent cannot narrow its own diff by under-reporting.
325pub fn diff(worktree: &Path) -> Result<String> {
326    // Stage everything first so that new files appear in the diff at all;
327    // untracked files are invisible to `git diff` otherwise.
328    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
352/// Commits the staged change inside the worktree, as the agent that wrote it.
353///
354/// The identity is set on the command rather than read from git config, for two
355/// reasons. A change an agent wrote should not be attributed to whichever human
356/// happened to start the run — the audit trail is the product. And a run that
357/// has already done all its work should not be thrown away at the last step
358/// because the machine has no `user.email` configured, which is the ordinary
359/// state of a CI runner.
360///
361/// Committing is as far as a run goes. Merging is a separate, human-initiated
362/// act: an approved change is ready to merge, not already merged.
363pub 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        // .invalid is reserved by RFC 2606 and can never resolve, which is the
369        // point: this address identifies an agent, it does not reach anyone.
370        .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
386/// An actor id reduced to something git will accept left of the `@`.
387///
388/// Identities are free-form strings; an id containing a space or an angle
389/// bracket would produce a malformed address and a commit git refuses.
390fn 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
409/// Removes a worktree, keeping the branch it was on.
410///
411/// The distinction matters: the branch holds the commit a run produced, and
412/// promotion, replay and the diff pane all read it from there. Taking the
413/// branch would take the change.
414pub fn release(repo: &Path, wt: &Worktree) -> Result<()> {
415    remove_checkout(repo, &wt.path)
416}
417
418/// Removes a worktree by path, for one that has outlived its run.
419pub 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
439/// Every worktree this project has created, by path.
440pub 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        // The reported defect: a worktree is a fresh checkout, so node_modules
477        // is absent and every check needing the toolchain fails for a reason
478        // that has nothing to do with the change.
479        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        // What an agent works out along the way should survive the run that
504        // worked it out — including a refused one, which is the run whose
505        // notes are worth the most.
506        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        // Written through the link, so it lands in the workspace rather than
528        // in a checkout that is about to be thrown away.
529        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        // A workspace nobody has taken notes in is not a broken workspace.
540        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        // A relative `../../` breaks the moment [worktree] base changes.
558        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        // The one a comparison of unresolved targets gets wrong: `read_link`
693        // hands back `sub`, which starts with nothing absolute, so it would
694        // read as pointing out of the checkout and the author would be told a
695        // directory the repository owns is not part of the repository.
696        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}