ostraka-runtime 1.2.0

The Ostraka engine: worktrees, gate execution, independent review, run records.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
//! Isolated checkouts, one per task.
//!
//! Work happens in a worktree so that a change is a diff on disk before it is
//! anything else — reviewable by an agent that did not write it, and discardable
//! without touching the branch anyone else is on.

use crate::{Error, Result};
use ostraka_core::identity::ActorId;
use std::path::{Path, PathBuf};
use std::process::Command;

#[derive(Debug, Clone)]
pub struct Worktree {
    path: PathBuf,
    branch: String,
}

impl Worktree {
    pub fn path(&self) -> &Path {
        &self.path
    }

    pub fn branch(&self) -> &str {
        &self.branch
    }
}

/// Creates a worktree for a run, branching from `base_ref`.
/// Whether this directory is somewhere git can make a worktree.
///
/// The whole runtime stands on `git worktree add`, so a directory that is not
/// a repository cannot run anything — and used to say so for the first time
/// two minutes into a run, in git's own words, after a vendor had been paid.
/// Asked once, cheaply, by whoever is about to promise that a run will work.
pub fn is_repository(dir: &Path) -> bool {
    Command::new("git")
        .args(["rev-parse", "--git-dir"])
        .current_dir(dir)
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .status()
        .is_ok_and(|status| status.success())
}

/// Whether this repository has a commit to branch from.
///
/// `git worktree add <path> HEAD` on a repository nobody has committed to
/// fails with `invalid reference: HEAD`, which is the second half of the same
/// problem `is_repository` catches the first half of: `git init` alone is not
/// enough to run in.
pub fn has_a_commit(repo: &Path) -> bool {
    Command::new("git")
        .args(["rev-parse", "--verify", "HEAD"])
        .current_dir(repo)
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .status()
        .is_ok_and(|status| status.success())
}

pub fn create(repo: &Path, base: &Path, run_id: &str, base_ref: &str) -> Result<Worktree> {
    let path = base.join(run_id);
    let branch = format!("ostraka/{run_id}");

    let out = Command::new("git")
        .args(["worktree", "add", "-b", &branch])
        .arg(&path)
        .arg(base_ref)
        .current_dir(repo)
        .output()?;

    if !out.status.success() {
        return Err(Error::Other(format!(
            "git worktree add failed: {}",
            String::from_utf8_lossy(&out.stderr).trim()
        )));
    }
    Ok(Worktree { path, branch })
}

/// Keeps a linked name out of git's sight, in this worktree only.
///
/// A link is not part of the change. Left visible, `git status` reports it as
/// something the agent added, so it counts as a touched path, it is committed
/// with the work, and a reviewer is shown a symlink nobody asked for — which
/// is what happened the first time a real vendor ran against a workspace with
/// notes in it.
///
/// Written to the worktree's own exclude file rather than to a `.gitignore`:
/// that file belongs to the repository and is not this to edit. A failure here
/// is not worth failing the run over — the worst case is the link showing up
/// in a diff, which is where this started.
fn exclude(worktree: &Path, name: &str) {
    let Ok(out) = Command::new("git")
        .args(["rev-parse", "--git-path", "info/exclude"])
        .current_dir(worktree)
        .output()
    else {
        return;
    };
    if !out.status.success() {
        return;
    }
    let path = String::from_utf8_lossy(&out.stdout).trim().to_string();
    if path.is_empty() {
        return;
    }
    let path = worktree.join(path);
    if let Some(parent) = path.parent() {
        let _ = std::fs::create_dir_all(parent);
    }
    let existing = std::fs::read_to_string(&path).unwrap_or_default();
    if existing.lines().any(|line| line.trim() == name) {
        return;
    }
    use std::io::Write;
    if let Ok(mut file) = std::fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(&path)
    {
        let _ = writeln!(file, "{name}");
    }
}

/// What went wrong getting a worktree ready to work in.
///
/// Separate from a gate failure on purpose. "The environment was not ready" and
/// "the change was rejected" are different answers, and reporting the first as
/// the second is what made a missing `node_modules` read as a refused change.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SetupProblem {
    pub step: String,
    pub reason: String,
}

/// Makes a fresh checkout usable: links what git ignores, runs what the project
/// says it needs.
///
/// Runs before the agent, not merely before the gate. An agent that cannot run
/// the project's own tools cannot see what it broke, which is how a run ends
/// with the agent having changed nothing and nobody knowing why.
/// Whether the workspace's notes reached this worktree as a link.
///
/// The fact the author prompt turns on, and it cannot be read off the
/// configuration. `[worktree]` naming notes is an intention; a repository that
/// tracks its own `notes/` keeps it, because [`prepare`] leaves what the
/// checkout brought rather than replacing it. Asking the config would then
/// tell an agent that a directory the repository owns is not part of the
/// repository, and invite it to write into the diff it is about to be judged
/// on.
///
/// A symlink is not enough on its own: a repository may track one under that
/// name. The property the sentence rests on is that it points *out* of the
/// checkout, so that is what is checked.
pub fn notes_linked(worktree: &Path) -> bool {
    linked(worktree, "notes")
}

/// Whether one of the workspace's directories reached this worktree as a link.
///
/// The same question for `skills/` as for `notes/`, and the same reason for
/// asking the worktree rather than the configuration: naming a directory in
/// `[worktree]` is an intention, and what the checkout brought stays.
pub fn linked(worktree: &Path, name: &str) -> bool {
    let path = worktree.join(name);
    let Ok(meta) = std::fs::symlink_metadata(&path) else {
        return false;
    };
    if !meta.file_type().is_symlink() {
        return false;
    }
    // Resolved, not compared as written. `read_link` hands back exactly what
    // the link holds, so a relative target — `../shared`, or `sub/dir` — never
    // starts with an absolute root and every one of them would read as
    // pointing out of the checkout. The links `prepare` makes are absolute,
    // but a repository can track a relative one under either name, and this is
    // what decides whether an author is told the directory is not its own.
    //
    // Canonicalising also settles a broken link: it fails, and a link to
    // nothing is not the workspace's directory.
    let (Ok(root), Ok(resolved)) = (worktree.canonicalize(), path.canonicalize()) else {
        return false;
    };
    !resolved.starts_with(&root)
}

pub fn prepare(
    project: &Path,
    worktree: &Path,
    config: &ostraka_core::config::WorktreeConfig,
    notes: Option<&Path>,
    skills: Option<&Path>,
    ceiling: Option<std::time::Duration>,
) -> std::result::Result<Vec<String>, SetupProblem> {
    let mut done = Vec::new();

    // The workspace's notes, linked rather than copied and rather than
    // configured. An agent writing here writes into the real directory, so
    // what it worked out survives a refusal — and because the link points out
    // of the checkout, none of it lands in the diff a reviewer judges. Notes
    // are what was learned; the diff is what was changed.
    let linked: Vec<(String, PathBuf)> = notes
        .map(|path| ("notes".to_string(), path.to_path_buf()))
        .into_iter()
        .chain(skills.map(|path| ("skills".to_string(), path.to_path_buf())))
        .chain(config.link.iter().map(|n| (n.clone(), project.join(n))))
        .collect();

    for (name, source) in linked {
        let name = &name;
        let target = worktree.join(name);
        if !source.exists() {
            // Said plainly rather than left to surface as an unrunnable check.
            // Declaring it means needing it, so its absence is the answer.
            return Err(SetupProblem {
                step: format!("link {name}"),
                reason: format!(
                    "{} is declared in [worktree] link and is not there; the worktree cannot be \
                     prepared without it",
                    source.display()
                ),
            });
        }
        // Something the repository tracks under that name already arrived with
        // the checkout, and it is not this to replace.
        if target.exists() || std::fs::symlink_metadata(&target).is_ok() {
            continue;
        }
        if let Some(parent) = target.parent() {
            let _ = std::fs::create_dir_all(parent);
        }
        // Absolute, so the link does not depend on how deep `[worktree] base`
        // puts the checkout — the fragility a relative `../../` would carry.
        let source = source.canonicalize().unwrap_or(source);
        if let Err(e) = symlink(&source, &target) {
            return Err(SetupProblem {
                step: format!("link {name}"),
                reason: format!("could not link {} into the worktree: {e}", source.display()),
            });
        }
        exclude(worktree, name);
        done.push(format!("link {name}"));
    }

    if let Some(command) = config.setup.as_deref().filter(|c| !c.trim().is_empty()) {
        let record = crate::gate::run_command(command, worktree, ceiling);
        if record.exit_code != Some(0) {
            let tail: Vec<&str> = record
                .stderr
                .lines()
                .rev()
                .take(6)
                .collect::<Vec<_>>()
                .into_iter()
                .rev()
                .collect();
            return Err(SetupProblem {
                step: "setup".to_string(),
                reason: format!(
                    "`{command}` exited with {}: {}",
                    record
                        .exit_code
                        .map(|c| c.to_string())
                        .unwrap_or_else(|| "no exit code".to_string()),
                    if tail.is_empty() {
                        "and said nothing".to_string()
                    } else {
                        tail.join(" / ")
                    }
                ),
            });
        }
        done.push(format!("setup `{command}`"));
    }

    Ok(done)
}

#[cfg(unix)]
fn symlink(source: &Path, target: &Path) -> std::io::Result<()> {
    std::os::unix::fs::symlink(source, target)
}

#[cfg(windows)]
fn symlink(source: &Path, target: &Path) -> std::io::Result<()> {
    if source.is_dir() {
        std::os::windows::fs::symlink_dir(source, target)
    } else {
        std::os::windows::fs::symlink_file(source, target)
    }
}

/// Lists paths modified inside a worktree.
///
/// Read from git, never from the agent's own account of what it did — an agent
/// that misreports its edits should not be able to shrink its own diff.
pub fn touched_paths(worktree: &Path) -> Result<Vec<String>> {
    let out = Command::new("git")
        .args(["status", "--porcelain"])
        .current_dir(worktree)
        .output()?;

    if !out.status.success() {
        return Err(Error::Other(format!(
            "git status failed: {}",
            String::from_utf8_lossy(&out.stderr).trim()
        )));
    }

    Ok(String::from_utf8_lossy(&out.stdout)
        .lines()
        .filter_map(|line| {
            // Porcelain v1: two status columns, a space, then the path.
            line.get(3..).map(|p| p.trim().to_string())
        })
        .filter(|p| !p.is_empty())
        .collect())
}

/// The change a run produced, as a diff against the base ref.
///
/// This is what a reviewer sees. It is read from git rather than from the
/// agent, so an agent cannot narrow its own diff by under-reporting.
pub fn diff(worktree: &Path) -> Result<String> {
    // Stage everything first so that new files appear in the diff at all;
    // untracked files are invisible to `git diff` otherwise.
    let add = Command::new("git")
        .args(["add", "-A"])
        .current_dir(worktree)
        .output()?;
    if !add.status.success() {
        return Err(Error::Other(format!(
            "git add failed: {}",
            String::from_utf8_lossy(&add.stderr).trim()
        )));
    }

    let out = Command::new("git")
        .args(["diff", "--cached"])
        .current_dir(worktree)
        .output()?;
    if !out.status.success() {
        return Err(Error::Other(format!(
            "git diff failed: {}",
            String::from_utf8_lossy(&out.stderr).trim()
        )));
    }
    Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}

/// Commits the staged change inside the worktree, as the agent that wrote it.
///
/// The identity is set on the command rather than read from git config, for two
/// reasons. A change an agent wrote should not be attributed to whichever human
/// happened to start the run — the audit trail is the product. And a run that
/// has already done all its work should not be thrown away at the last step
/// because the machine has no `user.email` configured, which is the ordinary
/// state of a CI runner.
///
/// Committing is as far as a run goes. Merging is a separate, human-initiated
/// act: an approved change is ready to merge, not already merged.
pub fn commit(worktree: &Path, message: &str, author: &ActorId) -> Result<()> {
    let out = Command::new("git")
        .arg("-c")
        .arg(format!("user.name={author}"))
        .arg("-c")
        // .invalid is reserved by RFC 2606 and can never resolve, which is the
        // point: this address identifies an agent, it does not reach anyone.
        .arg(format!(
            "user.email={}@ostraka.invalid",
            email_local(author)
        ))
        .args(["commit", "-m", message])
        .current_dir(worktree)
        .output()?;
    if !out.status.success() {
        return Err(Error::Other(format!(
            "git commit failed: {}",
            String::from_utf8_lossy(&out.stderr).trim()
        )));
    }
    Ok(())
}

/// An actor id reduced to something git will accept left of the `@`.
///
/// Identities are free-form strings; an id containing a space or an angle
/// bracket would produce a malformed address and a commit git refuses.
fn email_local(author: &ActorId) -> String {
    let cleaned: String = author
        .as_str()
        .chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' {
                c
            } else {
                '-'
            }
        })
        .collect();
    if cleaned.is_empty() {
        "agent".to_string()
    } else {
        cleaned
    }
}

/// Removes a worktree, keeping the branch it was on.
///
/// The distinction matters: the branch holds the commit a run produced, and
/// promotion, replay and the diff pane all read it from there. Taking the
/// branch would take the change.
pub fn release(repo: &Path, wt: &Worktree) -> Result<()> {
    remove_checkout(repo, &wt.path)
}

/// Removes a worktree by path, for one that has outlived its run.
pub fn release_path(repo: &Path, path: &Path) -> Result<()> {
    remove_checkout(repo, path)
}

fn remove_checkout(repo: &Path, path: &Path) -> Result<()> {
    let out = Command::new("git")
        .args(["worktree", "remove", "--force"])
        .arg(path)
        .current_dir(repo)
        .output()?;

    if !out.status.success() {
        return Err(Error::Other(format!(
            "git worktree remove failed: {}",
            String::from_utf8_lossy(&out.stderr).trim()
        )));
    }
    Ok(())
}

/// Every worktree this project has created, by path.
pub fn list(repo: &Path, base: &Path) -> Result<Vec<PathBuf>> {
    if !base.is_dir() {
        return Ok(Vec::new());
    }
    let _ = repo;
    let mut found: Vec<PathBuf> = std::fs::read_dir(base)?
        .filter_map(|e| e.ok().map(|e| e.path()))
        .filter(|p| p.is_dir())
        .collect();
    found.sort();
    Ok(found)
}

#[cfg(test)]
mod tests {
    use super::*;
    use ostraka_core::config::WorktreeConfig;

    fn scratch(name: &str) -> PathBuf {
        let path = std::env::temp_dir().join(format!("ostraka-prep-{}-{name}", std::process::id()));
        let _ = std::fs::remove_dir_all(&path);
        std::fs::create_dir_all(path.join("project")).expect("project");
        std::fs::create_dir_all(path.join("wt")).expect("worktree");
        path
    }

    fn prep_config(link: &[&str], setup: Option<&str>) -> WorktreeConfig {
        WorktreeConfig {
            base: "worktrees".into(),
            link: link.iter().map(|s| (*s).to_string()).collect(),
            setup: setup.map(str::to_string),
        }
    }

    #[test]
    fn what_git_ignores_is_linked_into_the_checkout() {
        // The reported defect: a worktree is a fresh checkout, so node_modules
        // is absent and every check needing the toolchain fails for a reason
        // that has nothing to do with the change.
        let dir = scratch("link");
        std::fs::create_dir_all(dir.join("project/node_modules")).expect("deps");
        std::fs::write(dir.join("project/node_modules/marker"), "here").expect("write");

        let done = prepare(
            &dir.join("project"),
            &dir.join("wt"),
            &prep_config(&["node_modules"], None),
            None,
            None,
            None,
        )
        .expect("prepares");

        assert_eq!(done, ["link node_modules"]);
        assert_eq!(
            std::fs::read_to_string(dir.join("wt/node_modules/marker")).expect("reads"),
            "here"
        );
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn the_workspaces_notes_reach_every_worktree_without_being_configured() {
        // What an agent works out along the way should survive the run that
        // worked it out — including a refused one, which is the run whose
        // notes are worth the most.
        let dir = scratch("notes");
        std::fs::create_dir_all(dir.join("notes")).expect("notes");
        std::fs::create_dir_all(dir.join("project")).expect("project");
        std::fs::write(dir.join("notes/earlier.md"), "what was worked out").expect("write");

        let done = prepare(
            &dir.join("project"),
            &dir.join("wt"),
            &prep_config(&[], None),
            Some(&dir.join("notes")),
            None,
            None,
        )
        .expect("prepares");

        assert_eq!(done, ["link notes"]);
        assert_eq!(
            std::fs::read_to_string(dir.join("wt/notes/earlier.md")).expect("reads"),
            "what was worked out"
        );

        // Written through the link, so it lands in the workspace rather than
        // in a checkout that is about to be thrown away.
        std::fs::write(dir.join("wt/notes/during.md"), "what was learned").expect("write");
        assert!(
            dir.join("notes/during.md").is_file(),
            "the note stayed in the worktree"
        );
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn a_worktree_without_notes_is_prepared_anyway() {
        // A workspace nobody has taken notes in is not a broken workspace.
        let dir = scratch("no-notes");
        std::fs::create_dir_all(dir.join("project")).expect("project");
        let done = prepare(
            &dir.join("project"),
            &dir.join("wt"),
            &prep_config(&[], None),
            None,
            None,
            None,
        )
        .expect("prepares");
        assert!(done.is_empty());
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn the_link_is_absolute_so_the_worktree_depth_does_not_matter() {
        // A relative `../../` breaks the moment [worktree] base changes.
        let dir = scratch("absolute");
        std::fs::create_dir_all(dir.join("project/node_modules")).expect("deps");
        std::fs::create_dir_all(dir.join("wt/deep/deeper")).expect("deep");
        prepare(
            &dir.join("project"),
            &dir.join("wt/deep/deeper"),
            &prep_config(&["node_modules"], None),
            None,
            None,
            None,
        )
        .expect("prepares");
        let link = std::fs::read_link(dir.join("wt/deep/deeper/node_modules")).expect("a link");
        assert!(link.is_absolute(), "{link:?}");
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn a_declared_link_that_is_absent_is_said_plainly() {
        let dir = scratch("missing");
        let problem = prepare(
            &dir.join("project"),
            &dir.join("wt"),
            &prep_config(&["node_modules"], None),
            None,
            None,
            None,
        )
        .expect_err("must refuse");
        assert_eq!(problem.step, "link node_modules");
        assert!(problem.reason.contains("is not there"), "{problem:?}");
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn something_the_repository_tracks_is_not_replaced_by_a_link() {
        let dir = scratch("tracked");
        std::fs::create_dir_all(dir.join("project/vendor")).expect("source");
        std::fs::create_dir_all(dir.join("wt/vendor")).expect("checked out");
        std::fs::write(dir.join("wt/vendor/theirs"), "tracked").expect("write");

        prepare(
            &dir.join("project"),
            &dir.join("wt"),
            &prep_config(&["vendor"], None),
            None,
            None,
            None,
        )
        .expect("prepares");
        assert!(
            dir.join("wt/vendor/theirs").is_file(),
            "the checkout lost a tracked file"
        );
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn a_setup_command_that_fails_reports_the_environment_not_the_change() {
        let dir = scratch("setup-fails");
        let problem = prepare(
            &dir.join("project"),
            &dir.join("wt"),
            &prep_config(&[], Some("echo no registry >&2; exit 1")),
            None,
            None,
            None,
        )
        .expect_err("must refuse");
        assert_eq!(problem.step, "setup");
        assert!(problem.reason.contains("no registry"), "{problem:?}");
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn a_setup_command_runs_inside_the_worktree() {
        let dir = scratch("setup-cwd");
        prepare(
            &dir.join("project"),
            &dir.join("wt"),
            &prep_config(&[], Some("pwd > where")),
            None,
            None,
            None,
        )
        .expect("prepares");
        let ran_in = std::fs::read_to_string(dir.join("wt/where")).expect("reads");
        assert!(ran_in.trim().ends_with("wt"), "{ran_in}");
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn nothing_declared_means_nothing_done() {
        let dir = scratch("nothing");
        let done = prepare(
            &dir.join("project"),
            &dir.join("wt"),
            &prep_config(&[], None),
            None,
            None,
            None,
        )
        .expect("prepares");
        assert!(done.is_empty());
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn an_identity_with_spaces_still_yields_a_usable_address() {
        assert_eq!(email_local(&ActorId::new("agent archon")), "agent-archon");
        assert_eq!(email_local(&ActorId::new("archon")), "archon");
    }

    #[test]
    fn an_empty_identity_falls_back_rather_than_producing_an_at_sign_alone() {
        assert_eq!(email_local(&ActorId::new("")), "agent");
    }
}

#[cfg(test)]
mod linked_tests {
    use super::linked;

    fn scratch(name: &str) -> std::path::PathBuf {
        let dir =
            std::env::temp_dir().join(format!("ostraka-linked-{}-{name}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(dir.join("wt")).expect("worktree");
        dir
    }

    #[cfg(unix)]
    #[test]
    fn a_relative_link_that_stays_inside_the_checkout_is_not_the_workspaces() {
        // The one a comparison of unresolved targets gets wrong: `read_link`
        // hands back `sub`, which starts with nothing absolute, so it would
        // read as pointing out of the checkout and the author would be told a
        // directory the repository owns is not part of the repository.
        let dir = scratch("relative-inside");
        std::fs::create_dir_all(dir.join("wt/sub")).expect("sub");
        std::os::unix::fs::symlink("sub", dir.join("wt/notes")).expect("link");
        assert!(!linked(&dir.join("wt"), "notes"));
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[cfg(unix)]
    #[test]
    fn a_link_out_of_the_checkout_is_the_workspaces_however_it_is_written() {
        let dir = scratch("outside");
        std::fs::create_dir_all(dir.join("shared")).expect("shared");
        std::os::unix::fs::symlink(dir.join("shared"), dir.join("wt/notes")).expect("absolute");
        std::os::unix::fs::symlink("../shared", dir.join("wt/skills")).expect("relative");
        assert!(linked(&dir.join("wt"), "notes"), "absolute target");
        assert!(linked(&dir.join("wt"), "skills"), "relative target");
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[cfg(unix)]
    #[test]
    fn a_link_to_nothing_is_not_a_directory_anybody_can_be_told_about() {
        let dir = scratch("broken");
        std::os::unix::fs::symlink("../never-existed", dir.join("wt/notes")).expect("link");
        assert!(!linked(&dir.join("wt"), "notes"));
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn a_real_directory_is_the_repositorys_own() {
        let dir = scratch("real");
        std::fs::create_dir_all(dir.join("wt/notes")).expect("notes");
        assert!(!linked(&dir.join("wt"), "notes"));
        assert!(!linked(&dir.join("wt"), "absent"));
        let _ = std::fs::remove_dir_all(&dir);
    }
}