wsb 0.275.7622

Workspace tools for every project under $PRJ: git across submodules, template stamping, GitHub, and the workbench
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
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
//! The machine, and the worktrees it holds.
//!
//! One daemon per MACHINE, not per worktree. It was per worktree, and that
//! made three things impossible: a page that shows every worktree at once, a
//! peer connection that is a property of the machine rather than of whichever
//! directory happened to start a server, and a single place for the identity
//! two machines pair with.
//!
//! # Where the machine's own state lives
//!
//! ```text
//! $PRJ/.state/hosts/<host>/     (prj's folder for this machine; see below)
//!   daemon.json                 the rendezvous: pid, port, version
//!   machine-identity.json       this machine's ed25519 peer identity and name
//!   config.toml                 the operator's settings
//!   plugins.json                the plugins the owner enabled
//!   extensions/                 the workbench's installed extensions
//!   outside/<digest>/           the local state of a checkout outside $PRJ
//!   daemon.log
//!   staging/                    incoming peer transfers, before they land
//! ```
//!
//! The folder is `$WSB_MACHINE_DIR` when that is set (the suites set it, so
//! they never touch the developer's daemon), else `$PRJ/.state/hosts/<host>`,
//! the folder `prj` already keeps for this machine, else `~/.wsb` on a machine
//! with no `$PRJ`. Three cases, each a place somebody chose, none a guess.
//!
//! Everything the machine keeps in a database — agent sessions, peers and
//! what continuity agreed with them, the GitHub mirror, every worktree's
//! catalogue and layout — is in the workshop's one database,
//! `$PRJ/.state/wsb.db` ([`wsb_prj::database_path`]). A worktree's `.wsb/` is
//! committed and holds none of it.

use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

use crate::peer::model::SharedWorktreeId;

/// Points the machine directory somewhere else. For the test suites, which
/// must never touch the developer's own daemon, identity, peers or database.
pub use wsb_prj::MACHINE_DIR_ENV;

/// This machine's state directory, created if it is not there.
///
/// What an older wsb kept in `~/.wsb` is brought across by `wsb migrate`
/// ([`adopt_old_machine_dir`]), never by reaching the folder.
pub fn machine_dir() -> Result<PathBuf, String> {
    let dir = machine_dir_path()?;
    std::fs::create_dir_all(&dir)
        .map_err(|e| format!("cannot create the machine state directory {}: {e}", dir.display()))?;
    Ok(dir)
}

/// Names this machine until it is given a name: what a test suite sets, so
/// every daemon it starts is told apart.
pub const MACHINE_NAME_ENV: &str = "WSB_MACHINE_NAME";

/// What this machine is called until somebody names it: [`MACHINE_NAME_ENV`]
/// when set, else its host name.
pub fn default_machine_name() -> String {
    if let Ok(name) = std::env::var(MACHINE_NAME_ENV) {
        if !name.trim().is_empty() {
            return name.trim().to_string();
        }
    }
    hostname().unwrap_or_else(|| "this machine".to_string())
}

fn hostname() -> Option<String> {
    #[cfg(unix)]
    {
        let output = crate::git_backend::command_without_repo_scope("hostname").output().ok()?;
        let name = String::from_utf8(output.stdout).ok()?;
        let name = name.trim().split('.').next()?.to_string();
        (!name.is_empty()).then_some(name)
    }
    #[cfg(windows)]
    {
        std::env::var("COMPUTERNAME").ok().filter(|n| !n.is_empty())
    }
}

/// This machine's name, as its identity in the machine directory holds it:
/// what wsb calls it, other machines show it as, and notes record they were
/// written on. `wsb machine name` changes it.
pub fn machine_name() -> Result<String, String> {
    let dir = machine_dir()?;
    Ok(crate::peer::identity::MachineIdentity::load_or_create(&dir, &default_machine_name())?
        .name()
        .to_string())
}

/// Where the machine directory is, without creating it.
fn machine_dir_path() -> Result<PathBuf, String> {
    if let Some(explicit) = wsb_prj::explicit_machine_dir().map_err(|e| e.to_string())? {
        return Ok(explicit);
    }
    match wsb_prj::Workshop::from_env() {
        Ok(prj) => prj.host_dir().map_err(|e| e.to_string()),
        Err(wsb_prj::WorkshopError::NotSet) => Ok(home_dir()?.join(".wsb")),
        Err(other) => Err(other.to_string()),
    }
}

/// What lived in `~/.wsb` before the machine's state moved under `$PRJ`, and
/// moves as it is.
const ADOPTED: [&str; 7] = [
    "machine-identity.json",
    "machine.db",
    "machine.db-wal",
    "machine.db-shm",
    "plugins.json",
    "daemon.log",
    "staging",
];

/// What lived there and does not move: the settings move rewritten, the
/// rest is dropped. `disposable/` is the logs of commands an older wsb ran
/// outside any worktree, which it filed in the nearest `.wsb` it found.
const REWRITTEN_OR_DROPPED: [&str; 4] = ["config.toml", "logs", "disposable", "daemon.json"];

/// The name a process gives `~/.wsb` while it moves it, so no other process
/// moves it at the same time.
const CLAIM_PREFIX: &str = ".wsb.moving-";

/// How long a process waits for another that is moving the old state.
const CLAIM_WAIT: std::time::Duration = std::time::Duration::from_secs(60);

/// Move the machine's state from `<home>/.wsb`, where it lived before `$PRJ`
/// held it, into `machine_dir` — once, by whichever process gets there first.
///
/// * The identity, the machine database (peers and their pinned keys), the
///   plugin list, the log and the staging folder move as they are. One
///   already present in both places is refused, naming both: the identity is
///   what paired machines know this one by, and choosing between two would
///   end one set of pairings silently.
/// * `config.toml` moves without the sections no part of wsb reads any more
///   (`[github]`, `[server]`). The first held a GitHub token in plain text,
///   which must not be carried into a folder `$PRJ` holds.
/// * `logs/`, `disposable/` and a stale `daemon.json` are removed; a `daemon.json` naming a
///   running daemon is refused — that daemon would keep serving from the old
///   place beside a new one.
/// * Anything else is refused by name, before anything moves.
///
/// # One mover
///
/// Many wsb processes start at once — a commit runs one per repository — and
/// two moving the same files would each find half of them gone. The mover
/// first renames `~/.wsb` to `~/.wsb.moving-<pid>`, which only one rename can
/// do; the others wait for that folder to go. One left by a process that
/// died is finished by the next.
///
/// Nothing to do when the two directories are the same, or `~/.wsb` is gone.
/// Returns what it did, one line per change.
pub fn adopt_old_machine_dir(home: &Path, machine_dir: &Path) -> Result<Vec<String>, String> {
    let old = home.join(".wsb");
    if same_place(&old, machine_dir) {
        return Ok(Vec::new());
    }
    let mine = home.join(format!("{CLAIM_PREFIX}{}", std::process::id()));
    let deadline = std::time::Instant::now() + CLAIM_WAIT;
    loop {
        match claims(home)?.into_iter().next() {
            Some((pid, claim)) if pid != std::process::id() && crate::ops::process::is_alive(pid) => {
                if std::time::Instant::now() >= deadline {
                    return Err(format!(
                        "process {pid} has been moving this machine's state out of {} for over \
                         {} seconds ({} is still there). Wait for it, or stop it and run again.",
                        old.display(),
                        CLAIM_WAIT.as_secs(),
                        claim.display()
                    ));
                }
                std::thread::sleep(std::time::Duration::from_millis(200));
            }
            Some((_, claim)) => {
                // Its mover died part way; this process finishes the move.
                match std::fs::rename(&claim, &mine) {
                    Ok(()) => return move_claimed(&mine, &old, machine_dir),
                    Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
                    Err(e) => {
                        return Err(format!("cannot take over {}: {e}", claim.display()));
                    }
                }
            }
            None => {
                if !old.is_dir() {
                    return Ok(Vec::new());
                }
                check_old_machine_dir(&old, machine_dir)?;
                match std::fs::rename(&old, &mine) {
                    Ok(()) => return move_claimed(&mine, &old, machine_dir),
                    // Another process claimed it first: wait for that one.
                    Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
                    Err(e) => {
                        return Err(format!("cannot claim {} to move it: {e}", old.display()));
                    }
                }
            }
        }
    }
}

/// Every folder a mover has claimed, with its pid.
fn claims(home: &Path) -> Result<Vec<(u32, PathBuf)>, String> {
    let mut found = Vec::new();
    let entries = std::fs::read_dir(home).map_err(|e| format!("cannot read {}: {e}", home.display()))?;
    for entry in entries {
        let entry = entry.map_err(|e| format!("cannot read {}: {e}", home.display()))?;
        let name = entry.file_name().to_string_lossy().into_owned();
        if let Some(pid) = name.strip_prefix(CLAIM_PREFIX) {
            let pid = pid.parse::<u32>().map_err(|_| {
                format!(
                    "{} is not a folder wsb made; move it away and run again",
                    entry.path().display()
                )
            })?;
            found.push((pid, entry.path()));
        }
    }
    found.sort();
    Ok(found)
}

/// Refuse to move what this function does not know, or a live daemon's.
fn check_old_machine_dir(old: &Path, machine_dir: &Path) -> Result<(), String> {
    for name in entry_names(old)? {
        if !ADOPTED.contains(&name.as_str()) && !REWRITTEN_OR_DROPPED.contains(&name.as_str()) {
            return Err(format!(
                "{} is not machine state wsb wrote; move it out of {} and run again",
                old.join(&name).display(),
                old.display()
            ));
        }
        let to = machine_dir.join(&name);
        if (ADOPTED.contains(&name.as_str()) || name == "config.toml") && to.exists() {
            return Err(format!(
                "{} and {} both exist; this machine has one identity, one database and one \
                 set of settings. Keep the one you mean, remove the other, and run again.",
                old.join(&name).display(),
                to.display()
            ));
        }
    }
    if let Some(published) = MachineRendezvous::published_by(old)? {
        if crate::ops::process::is_alive(published.pid) {
            return Err(format!(
                "wsb-daemon {} (pid {}) is still running from {}, where this machine's state \
                 used to live. Stop it (kill {}) and run again: the state then moves to {}.",
                published.version,
                published.pid,
                old.display(),
                published.pid,
                machine_dir.display()
            ));
        }
    }
    Ok(())
}

fn entry_names(dir: &Path) -> Result<Vec<String>, String> {
    let mut names: Vec<String> = std::fs::read_dir(dir)
        .map_err(|e| format!("cannot read {}: {e}", dir.display()))?
        .map(|entry| {
            entry
                .map(|entry| entry.file_name().to_string_lossy().into_owned())
                .map_err(|e| format!("cannot read {}: {e}", dir.display()))
        })
        .collect::<Result<_, _>>()?;
    names.sort();
    Ok(names)
}

/// Move everything out of a claimed folder, then remove it.
fn move_claimed(claim: &Path, old: &Path, machine_dir: &Path) -> Result<Vec<String>, String> {
    // Checked again: a folder taken over from a dead mover was never checked
    // by this process.
    check_old_machine_dir(claim, machine_dir)?;
    std::fs::create_dir_all(machine_dir)
        .map_err(|e| format!("cannot create {}: {e}", machine_dir.display()))?;
    let mut done = Vec::new();
    for name in entry_names(claim)? {
        let from = claim.join(&name);
        let to = machine_dir.join(&name);
        let was = old.join(&name);
        match name.as_str() {
            "logs" | "disposable" => {
                std::fs::remove_dir_all(&from)
                    .map_err(|e| format!("cannot remove {}: {e}", from.display()))?;
                done.push(format!("removed {}", was.display()));
            }
            "daemon.json" => {
                std::fs::remove_file(&from)
                    .map_err(|e| format!("cannot remove {}: {e}", from.display()))?;
                done.push(format!("removed the stale {}", was.display()));
            }
            "config.toml" => {
                let text = std::fs::read_to_string(&from)
                    .map_err(|e| format!("cannot read {}: {e}", from.display()))?;
                let mut table: toml::Table = text
                    .parse()
                    .map_err(|e| format!("{} is not a settings file: {e}", was.display()))?;
                let dropped: Vec<String> = ["github", "server"]
                    .into_iter()
                    .filter(|section| table.remove(*section).is_some())
                    .map(str::to_string)
                    .collect();
                std::fs::write(&to, toml::to_string_pretty(&table).map_err(|e| e.to_string())?)
                    .map_err(|e| format!("cannot write {}: {e}", to.display()))?;
                std::fs::remove_file(&from)
                    .map_err(|e| format!("cannot remove {}: {e}", from.display()))?;
                done.push(if dropped.is_empty() {
                    format!("moved {} to {}", was.display(), to.display())
                } else {
                    format!(
                        "moved {} to {}, without its unread [{}]",
                        was.display(),
                        to.display(),
                        dropped.join("], [")
                    )
                });
            }
            _ => {
                std::fs::rename(&from, &to)
                    .map_err(|e| format!("cannot move {} to {}: {e}", was.display(), to.display()))?;
                done.push(format!("moved {} to {}", was.display(), to.display()));
            }
        }
    }
    std::fs::remove_dir(claim).map_err(|e| format!("cannot remove {}: {e}", claim.display()))?;
    done.push(format!("removed {}", old.display()));
    Ok(done)
}

fn same_place(one: &Path, other: &Path) -> bool {
    match (crate::platform::canonicalize(one), crate::platform::canonicalize(other)) {
        (Ok(one), Ok(other)) => one == other,
        _ => one == other,
    }
}

/// Where incoming transfers are staged before they are applied.
///
/// Under the machine directory rather than inside the worktree: a partial
/// transfer must never be visible in a working tree, not even for the instant
/// between writing and renaming, because a build or an editor watching that
/// tree would see it.
pub fn staging_dir() -> Result<PathBuf, String> {
    let dir = machine_dir()?.join("staging");
    std::fs::create_dir_all(&dir)
        .map_err(|e| format!("cannot create {}: {e}", dir.display()))?;
    Ok(dir)
}

pub fn home_dir() -> Result<PathBuf, String> {
    #[cfg(windows)]
    let key = "APPDATA";
    #[cfg(not(windows))]
    let key = "HOME";
    std::env::var_os(key)
        .filter(|v| !v.is_empty())
        .map(PathBuf::from)
        .ok_or_else(|| {
            format!("{key} is not set, so this machine has no state directory to use")
        })
}

/// What the daemon publishes about itself, machine-wide.
///
/// One file, one daemon, one machine. The per-worktree rendezvous it replaced
/// meant a machine with six worktrees had six daemons, six ports and six
/// copies of everything they held.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MachineRendezvous {
    pub version: String,
    pub pid: u32,
    pub http_address: std::net::IpAddr,
    pub http_port: u16,
    /// The machine's peer identity, so a command can report it without
    /// reading the identity file itself.
    pub machine_id: String,
    pub machine_name: String,
    /// The address the peer listener is bound to.
    ///
    /// Recorded separately from `http_address` because the two are bound
    /// differently on purpose: the workbench is unauthenticated and stays on
    /// loopback, the peer port is authenticated and encrypted and answers
    /// every interface. A person asking "can the other machine reach me?" is
    /// asking about this one.
    pub peer_address: std::net::IpAddr,
    /// The port peers connect to. Zero when peer listening is off.
    pub peer_port: u16,
    pub started_at: u64,
    /// A secret this daemon minted at startup, for the acts that need to know
    /// the caller is the operator of THIS machine rather than a passer-by.
    ///
    /// # Why a token, and not nothing
    ///
    /// Anything that can reach the port could otherwise stop the daemon, and
    /// the workbench is reachable from other machines under `--listen`. The
    /// same person can send the process a signal, so what stopping needs to
    /// establish is no more than that.
    ///
    /// What stopping actually needs to establish is that the caller is the
    /// account this daemon runs as, and this file already says so — it is
    /// written 0600, so being able to READ it is the proof. A caller across the
    /// network cannot; another account on this machine cannot; the operator's
    /// own command line reads it to find the port before it does anything
    /// else.
    ///
    /// Absent when an older daemon wrote the file, which is not a special case
    /// to carry: no token means no proof, and the caller falls back to earning
    /// a session exactly as it did before.
    #[serde(default)]
    pub control_token: String,
}

const RENDEZVOUS: &str = "daemon.json";

/// A fresh secret for a daemon's control acts.
///
/// Here rather than in the daemon so it sits beside the field it fills and the
/// paragraph that explains what it is for — and because the randomness this
/// draws on is already a dependency of this crate.
///
/// From the operating system, not from anything about the machine: a token
/// derived from the pid, the port or the start time is one anybody can
/// compute, which is not a secret at all.
pub fn new_control_token() -> String {
    use rand::RngCore;

    let mut bytes = [0u8; 32];
    rand::thread_rng().fill_bytes(&mut bytes);
    bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}

/// The two fields every build of this file has carried, and the only two a
/// build needs to say WHOSE file it is looking at.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct Published {
    pub version: String,
    pub pid: u32,
}

impl MachineRendezvous {
    pub fn path_in(machine_dir: &Path) -> PathBuf {
        machine_dir.join(RENDEZVOUS)
    }

    /// Read it, or `None` when no daemon this build can use has published one.
    ///
    /// Three outcomes, and the difference between the last two matters:
    ///
    /// * A rendezvous this build understands → `Some`.
    /// * No file → `None`.
    /// * A rendezvous **another build** published → `None`, with a warning.
    ///   The two halves already refuse to speak across builds, so a file this
    ///   one cannot read describes a daemon it could not have used anyway.
    ///   Failing hard here made every command fail — including the `wsb daemon
    ///   restart` that fixes it — over a machine-local file that is simply
    ///   older than the binary reading it. Whatever is actually holding the
    ///   port is still found: starting a daemon over it refuses by name.
    /// * Anything else at that path → an error. Something that is not this
    ///   system's file owns it, and writing over that compounds whatever it is.
    pub fn read(machine_dir: &Path) -> Result<Option<Self>, String> {
        let path = Self::path_in(machine_dir);
        let bytes = match std::fs::read(&path) {
            Ok(bytes) => bytes,
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
            Err(e) => return Err(format!("cannot read {}: {e}", path.display())),
        };
        match serde_json::from_slice(&bytes) {
            Ok(rendezvous) => Ok(Some(rendezvous)),
            Err(error) => match serde_json::from_slice::<Published>(&bytes) {
                Ok(published) => {
                    crate::output::message(
                        crate::output::record::Level::Warn,
                        "daemon-rendezvous-other-build",
                        format!(
                            "{} was published by wsb-daemon {} (pid {}), which this build \
                             cannot read. Run `wsb daemon restart` to replace it.",
                            path.display(),
                            published.version,
                            published.pid
                        ),
                    );
                    Ok(None)
                }
                Err(_) => Err(format!(
                    "{} is not a daemon rendezvous file: {error}",
                    path.display()
                )),
            },
        }
    }

    /// Who published the file at this path, whatever build wrote it.
    ///
    /// Answers when [`read`](Self::read) cannot: a daemon of another build
    /// still holds the port, and "this build cannot read its file" is not the
    /// same as "nothing is running". A daemon about to bind asks this before
    /// it decides it is the only one.
    pub fn published_by(machine_dir: &Path) -> Result<Option<Published>, String> {
        let path = Self::path_in(machine_dir);
        let bytes = match std::fs::read(&path) {
            Ok(bytes) => bytes,
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
            Err(e) => return Err(format!("cannot read {}: {e}", path.display())),
        };
        Ok(serde_json::from_slice(&bytes).ok())
    }

    /// Publish it: written to a sibling and renamed, so a reader sees a whole
    /// file or none.
    pub fn write(&self, machine_dir: &Path) -> Result<(), String> {
        std::fs::create_dir_all(machine_dir)
            .map_err(|e| format!("cannot create {}: {e}", machine_dir.display()))?;
        let path = Self::path_in(machine_dir);
        let staged = machine_dir.join(format!(".{RENDEZVOUS}.{}", std::process::id()));
        let encoded = serde_json::to_vec_pretty(self)
            .map_err(|e| format!("cannot encode the daemon rendezvous: {e}"))?;
        let outcome = (|| -> Result<(), String> {
            super::rendezvous::create_private(&staged)?;
            std::fs::write(&staged, &encoded)
                .map_err(|e| format!("cannot write {}: {e}", staged.display()))?;
            std::fs::rename(&staged, &path)
                .map_err(|e| format!("cannot publish {}: {e}", path.display()))
        })();
        if outcome.is_err() {
            let _ = std::fs::remove_file(&staged);
        }
        outcome
    }

    /// Remove it if it still names the daemon with this pid, and say whether
    /// it did.
    ///
    /// Never unconditionally. Every caller means one daemon's file — its own
    /// on the way out, or one it found dead — and by the time it acts another
    /// daemon may have published over it. A daemon that removed whatever file
    /// was there on exit took the live daemon's with it, and every command
    /// after reported "the daemon published no rendezvous file" while that
    /// daemon ran on, healthy.
    pub fn remove_if_published_by(machine_dir: &Path, pid: u32) -> Result<bool, String> {
        match Self::published_by(machine_dir)? {
            Some(published) if published.pid == pid => {}
            _ => return Ok(false),
        }
        let path = Self::path_in(machine_dir);
        match std::fs::remove_file(&path) {
            Ok(()) => Ok(true),
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
            Err(e) => Err(format!("cannot remove {}: {e}", path.display())),
        }
    }
}

/// A worktree on this machine, as this machine names it.
///
/// `<project>:<worktree>` for a worktree of a `$PRJ` project — `main` for its
/// `main-worktree` — which is what `prj` calls it and what a person reads.
/// Anywhere else, `~` and 16 hex digits of the sha256 of its canonical path:
/// a checkout outside `$PRJ` has no name anybody gave it, and its directory's
/// name alone is not unique. `prj` names never contain `:` and never start
/// with `~`, so the two forms cannot collide.
///
/// Local, and never sent to a peer: two machines name their worktrees by
/// where they are on each machine. What two machines compare is the
/// [`SharedWorktreeId`] a worktree carries.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct WorktreeId(String);

impl WorktreeId {
    pub fn parse(raw: &str) -> Result<Self, String> {
        if let Some(hex) = raw.strip_prefix('~') {
            if hex.len() == 16 && hex.chars().all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)) {
                return Ok(Self(raw.to_string()));
            }
        } else if let Some((project, worktree)) = raw.split_once(':') {
            if wsb_prj::is_valid_name(project) && wsb_prj::is_valid_name(worktree) {
                return Ok(Self(raw.to_string()));
            }
        }
        Err(format!(
            "'{raw}' is not a worktree id: <project>:<worktree>, or ~ and 16 hex digits"
        ))
    }

    fn of_project(project: &str, worktree: &str) -> Self {
        Self(format!("{project}:{worktree}"))
    }

    fn of_path(canonical: &Path) -> Self {
        use sha2::{Digest, Sha256};
        let digest = Sha256::digest(canonical.to_string_lossy().as_bytes());
        let hex: String = digest.iter().take(8).map(|byte| format!("{byte:02x}")).collect();
        Self(format!("~{hex}"))
    }

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

    /// `(project, worktree)` for a worktree of a `$PRJ` project; none for a
    /// checkout named by its path.
    pub fn in_project(&self) -> Option<(&str, &str)> {
        self.0.split_once(':')
    }
}

impl TryFrom<String> for WorktreeId {
    type Error = String;
    fn try_from(raw: String) -> Result<Self, String> {
        Self::parse(&raw)
    }
}

impl From<WorktreeId> for String {
    fn from(id: WorktreeId) -> String {
        id.0
    }
}

impl std::fmt::Display for WorktreeId {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.0)
    }
}

/// Where a worktree is, and what it is called: everything about it that the
/// filesystem decides.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorktreePlace {
    pub id: WorktreeId,
    /// Its `$PRJ` project; none for a checkout outside `$PRJ`.
    pub project: Option<String>,
    /// `main`, `<name>` for `<name>-worktree`, or the directory's name.
    pub name: String,
    /// Canonical.
    pub root: PathBuf,
}

/// Name the worktree whose super root is `root`.
///
/// A `$PRJ` worktree is the folder `prj` gives it, exactly: a repository
/// elsewhere inside a project folder (a snapshot, a stray clone) is not one of
/// the project's worktrees, and is named by its path like any checkout outside
/// `$PRJ`. With no `$PRJ` set every checkout is.
pub fn place_worktree(root: &Path) -> Result<WorktreePlace, String> {
    let prj = match wsb_prj::Workshop::from_env() {
        Ok(prj) => Some(prj),
        Err(wsb_prj::WorkshopError::NotSet) => None,
        Err(other) => return Err(other.to_string()),
    };
    place_worktree_in(prj.as_ref(), root)
}

/// [`place_worktree`] against a given `$PRJ`, or none.
pub fn place_worktree_in(prj: Option<&wsb_prj::Workshop>, root: &Path) -> Result<WorktreePlace, String> {
    let canonical = crate::platform::canonicalize(root)
        .map_err(|e| format!("cannot resolve {}: {e}", root.display()))?;
    if let Some(prj) = prj {
        if let Ok(location) = prj.locate(&canonical) {
            if let Some(worktree) = location.worktree {
                let folder = location
                    .workspace
                    .dir()
                    .join(wsb_prj::worktree_folder(&worktree));
                if same_place(&folder, &canonical) {
                    return Ok(WorktreePlace {
                        id: WorktreeId::of_project(location.workspace.name(), &worktree),
                        project: Some(location.workspace.name().to_string()),
                        name: worktree,
                        root: canonical,
                    });
                }
            }
        }
    }
    let name = canonical
        .file_name()
        .map(|name| name.to_string_lossy().into_owned())
        .unwrap_or_else(|| canonical.display().to_string());
    Ok(WorktreePlace {
        id: WorktreeId::of_path(&canonical),
        project: None,
        name,
        root: canonical,
    })
}

/// One worktree the daemon knows about.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RegisteredWorktree {
    pub id: WorktreeId,
    pub project: Option<String>,
    pub name: String,
    /// What two machines compare: the repository's family and this worktree's
    /// name. Absent until the superproject has a commit — a repository with no
    /// history has nothing a peer could recognise it by, and is still a
    /// worktree on this machine.
    pub shared: Option<SharedWorktreeId>,
    /// Where it is ON THIS MACHINE. Never sent to a peer as an identity.
    pub super_root: PathBuf,
    /// The superproject's root commit, when it has one.
    pub root_commit: Option<String>,
    /// Unix seconds a command was last run in it.
    pub last_seen: u64,
    /// Whether the directory is there right now. A worktree on an unmounted
    /// drive is registered and unavailable, which is a state rather than a
    /// reason to forget it.
    pub available: bool,
}

impl RegisteredWorktree {
    /// What a person reads: `<project>/<worktree>`, or the folder's name
    /// outside `$PRJ`.
    pub fn display_name(&self) -> String {
        match &self.project {
            Some(project) => format!("{project}/{}", self.name),
            None => self.name.clone(),
        }
    }
}

/// The family a repository belongs to: its superproject's root commit,
/// hashed.
///
/// Two clones of one repository derive the same family on machines that have
/// never spoken, without anybody configuring anything — which is what makes
/// pairing work at all. A path would be different on every machine, and a
/// random id would have to be carried between them by hand.
pub fn family_of(root_commit: &str) -> Result<String, String> {
    if root_commit.is_empty() {
        return Err("a repository with no commits has no family".to_string());
    }
    use sha2::{Digest, Sha256};
    let digest = Sha256::digest(root_commit.as_bytes());
    Ok(digest.iter().take(8).map(|byte| format!("{byte:02x}")).collect())
}

/// A worktree's continuity identity, as its checkout gives it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Continuity {
    /// The superproject's root commit; none before the first commit.
    pub root_commit: Option<String>,
    /// What a peer knows it by, or why it has nothing a peer could know.
    pub shared: Result<SharedWorktreeId, String>,
}

/// Read a worktree's continuity identity from its checkout.
///
/// Two states have no identity, and each says why: a repository with no
/// commit yet, and a checkout outside `$PRJ` whose folder name two machines
/// could not share. Neither stops the worktree being worked on here.
pub fn identify_shared(place: &WorktreePlace) -> Result<Continuity, String> {
    let facts = crate::peer::compatibility::RepositoryFacts::read(
        crate::peer::model::RepositoryId::root(),
        &place.root,
        &place.root.join(".git"),
    )?;
    if !facts.present {
        return Err(format!("{} is not a git repository", place.root.display()));
    }
    if facts.root_commit.is_empty() {
        return Ok(Continuity {
            root_commit: None,
            shared: Err(
                "its superproject has no commits yet, so a peer has nothing to recognise it by"
                    .to_string(),
            ),
        });
    }
    let family = family_of(&facts.root_commit)?;
    Ok(Continuity {
        shared: SharedWorktreeId::of(&family, &place.name),
        root_commit: Some(facts.root_commit),
    })
}

#[cfg(test)]
#[path = "machine_tests.rs"]
mod tests;