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
//! Rebuild wsb from its own repository and put the result in place of the
//! installed pair.
//!
//! wsb is installed as a PAIR — `wsb` and `wsb-daemon`, in one directory,
//! sharing a database schema and a control protocol, neither ever searched for
//! on PATH ([`crate::daemon::launch::beside`]). So an upgrade is not "replace a
//! binary": it is replace both, or replace neither. Half an upgrade is a CLI
//! talking to a daemon that reads its tables differently.
//!
//! # The order things happen in
//!
//! Nothing installed is touched until a complete, working replacement exists:
//!
//!   1. find the installed pair, and refuse a directory that cannot be written
//!      to — before twenty minutes of compiling, not after;
//!   2. bring one managed checkout to the ref: nobody edits it, so it is reset
//!      to exactly what the remote says every time;
//!   3. build BOTH binaries with QuickRust's cargo, which decides where the
//!      build goes and keeps every dependency it has compiled before;
//!   4. start each one and read its version back, because a binary that builds
//!      and cannot start is exactly what must not be installed;
//!   5. only then, swap them.
//!
//! # Replacing a running program
//!
//! The binary being replaced may be the one executing this code, and on Unix
//! that is fine: `rename` swings the directory entry to a new inode while the
//! old one stays alive for anybody running it. Writing INTO the file is not —
//! that is `ETXTBSY`, or a half-written binary. So each new binary is written
//! to a temporary name in the SAME directory (rename cannot cross filesystems)
//! and renamed over the old.
//!
//! A running daemon keeps the inode it started with. Bringing the machine
//! over to the new pair is [`bring_the_machine_over`], done by the NEW `wsb`:
//! it knows how its own service definition is rendered, and it migrates the
//! machine's state between stopping the old daemon and starting the new.

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

use crate::daemon::launch::Half;

/// The ref an upgrade builds when none is named.
pub const DEFAULT_REF: &str = "main";

/// What an upgrade did, or under `--dry-run` would do.
#[derive(Debug, Clone)]
pub struct Report {
    /// What the installed `wsb` said it was, when it said.
    pub from_version: Option<String>,
    pub to_version: String,
    pub reference: String,
    pub commit: String,
    pub install_dir: PathBuf,
    /// Replaced, or under `--dry-run` the paths that would be.
    pub replaced: Vec<PathBuf>,
    pub dry_run: bool,
}

/// Rebuild from `reference` and replace the installed pair.
pub fn run(reference: &str, dry_run: bool) -> Result<Report, String> {
    let install_dir = install_root_bin(
        std::env::var_os("QRUST_HOME").map(PathBuf::from),
        std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).map(PathBuf::from),
    )?;
    // In the machine directory: one checkout per machine, never inside the
    // worktree the command is run from, and never one somebody works in.
    let checkout = crate::daemon::machine::machine_dir()?.join("upgrade").join("wsb");
    run_in(&install_dir, &checkout, &repository()?, reference, dry_run)
}

/// [`run`], with every place it acts on named.
fn run_in(
    install_dir: &Path,
    checkout: &Path,
    repository: &str,
    reference: &str,
    dry_run: bool,
) -> Result<Report, String> {
    installed_pair(install_dir)?;
    writable(install_dir)?;
    crate::outln!("upgrade: the installed pair is in {}", install_dir.display());
    if let Ok(own) = std::env::current_exe() {
        if own.parent() != Some(install_dir) {
            crate::outln!(
                "upgrade: this wsb runs from {}, which is not the installed one and is left as it is",
                own.display()
            );
        }
    }

    crate::outln!("upgrade: fetching {repository} ({reference})");
    let commit = fetch(checkout, repository, reference)?;

    crate::outln!("upgrade: building wsb and wsb-daemon at {}", &commit[..commit.len().min(12)]);
    let built = build(install_dir, &checkout.join("wsb"))?;

    let from_version = match started_version(&install_dir.join(Half::Cli.file_name()), Half::Cli) {
        Ok(version) => Some(version),
        Err(why) => {
            // Said, not hidden: replacing a wsb that no longer starts is one
            // of the things this command is for.
            crate::outln!("upgrade: the installed wsb does not report its version ({why}); it is replaced all the same");
            None
        }
    };
    let (to_version, replaced) = replace(&built, install_dir, dry_run)?;
    Ok(Report {
        from_version,
        to_version,
        reference: reference.to_string(),
        commit,
        install_dir: install_dir.to_path_buf(),
        replaced,
        dry_run,
    })
}

/// QuickRust's install root: `$QRUST_HOME/bin`, else `~/.qrust/bin`, which is
/// where `qrust install` put both halves — and where its `cargo` and `qrust`
/// are.
///
/// Not where the running executable is. A wsb run out of a build directory is
/// not installed, and upgrading "where this binary is" would replace that
/// build's binaries and leave the installed pair, the one every shell runs, as
/// old as it was.
fn install_root_bin(qrust_home: Option<PathBuf>, home: Option<PathBuf>) -> Result<PathBuf, String> {
    let non_empty = |p: Option<PathBuf>| p.filter(|p| !p.as_os_str().is_empty());
    if let Some(root) = non_empty(qrust_home) {
        return Ok(root.join("bin"));
    }
    non_empty(home).map(|home| home.join(".qrust").join("bin")).ok_or_else(|| {
        "neither QRUST_HOME nor a home directory is set, so where QuickRust installed wsb \
         cannot be known"
            .to_string()
    })
}

/// Refused unless both halves are in `directory`: an upgrade replaces an
/// installation, it does not create one.
fn installed_pair(directory: &Path) -> Result<(), String> {
    let missing: Vec<&str> = [Half::Cli, Half::Daemon]
        .into_iter()
        .map(Half::file_name)
        .filter(|name| !directory.join(name).is_file())
        .collect();
    if missing.is_empty() {
        return Ok(());
    }
    Err(format!(
        "wsb is not installed in {} ({} missing), so there is nothing to upgrade there.\n  \
         Install the pair with `qrust install wsb wsb-daemon --force`, or from a checkout's wsb/ \
         with `{}`.",
        directory.display(),
        missing.join(", "),
        crate::daemon::launch::INSTALL_PAIR
    ))
}

/// Refuse a directory that cannot be written to, before anything is built.
fn writable(directory: &Path) -> Result<(), String> {
    let probe = directory.join(format!(".wsb-upgrade-probe-{}", std::process::id()));
    match std::fs::write(&probe, b"") {
        Ok(()) => std::fs::remove_file(&probe)
            .map_err(|e| format!("cannot remove the probe {}: {e}", probe.display())),
        Err(e) => Err(format!(
            "wsb is installed in {}, which cannot be written to: {e}\n  \
             An upgrade replaces both binaries there. Run it with the rights that directory \
             needs, or install wsb somewhere you own.",
            directory.display()
        )),
    }
}

/// Where wsb's own source comes from: the package's `repository`, baked in by
/// cargo, so an installed binary needs no clone of its own to upgrade from.
fn repository() -> Result<String, String> {
    let declared = env!("CARGO_PKG_REPOSITORY").trim();
    if declared.is_empty() {
        return Err(
            "this wsb was built from a package that declares no `repository`, so it does not \
             know where to upgrade itself from"
                .to_string(),
        );
    }
    Ok(declared.to_string())
}

/// Bring the managed checkout to `reference` — a branch or a tag of
/// `repository` — and say which commit that is.
///
/// Exactly the ref, every time: a local edit, an untracked file and a commit
/// made there are all discarded. Nobody works in this checkout, and a build of
/// "the ref plus whatever was lying around" would install something no
/// commit names.
fn fetch(checkout: &Path, repository: &str, reference: &str) -> Result<String, String> {
    if !checkout.join(".git").exists() {
        // Leftovers of a clone that did not finish are debris, not somebody's
        // work: this path is the machine directory's, and has no `.git`.
        if checkout.exists() {
            std::fs::remove_dir_all(checkout).map_err(|e| {
                format!("cannot clear the incomplete checkout at {}: {e}", checkout.display())
            })?;
        }
        if let Some(parent) = checkout.parent() {
            std::fs::create_dir_all(parent)
                .map_err(|e| format!("cannot create {}: {e}", parent.display()))?;
        }
        git(None, &["clone", "--quiet", "--no-checkout", repository, &checkout.display().to_string()])?;
    }
    // The repository a newer wsb declares, not the one the checkout was first
    // cloned from.
    git(Some(checkout), &["remote", "set-url", "origin", repository])?;
    git(
        Some(checkout),
        &[
            "fetch",
            "--quiet",
            "--prune",
            "--force",
            "origin",
            "+refs/heads/*:refs/remotes/origin/*",
            "+refs/tags/*:refs/tags/*",
        ],
    )?;
    // A branch first, then a tag: asked for by name, each in its own
    // namespace, rather than by trying one and taking any failure to mean the
    // other.
    let commit = [format!("refs/remotes/origin/{reference}"), format!("refs/tags/{reference}")]
        .iter()
        .find_map(|name| resolved(checkout, name))
        .ok_or_else(|| format!("{repository} has no branch or tag named '{reference}'"))?;
    git(Some(checkout), &["-c", "advice.detachedHead=false", "checkout", "--quiet", "--force", "--detach", &commit])?;
    git(Some(checkout), &["clean", "-ffdxq"])?;
    Ok(commit)
}

/// The commit `name` points at, or `None` where there is no such ref.
fn resolved(checkout: &Path, name: &str) -> Option<String> {
    let output = crate::git_backend::git_command()
        .current_dir(checkout)
        .args(["rev-parse", "--verify", "--quiet", &format!("{name}^{{commit}}")])
        .output()
        .ok()?;
    let commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
    (output.status.success() && !commit.is_empty()).then_some(commit)
}

fn git(directory: Option<&Path>, args: &[&str]) -> Result<(), String> {
    let mut command = crate::git_backend::git_command();
    if let Some(directory) = directory {
        command.current_dir(directory);
    }
    let output = command
        .args(args)
        .output()
        .map_err(|e| format!("cannot run git {}: {e}", args.join(" ")))?;
    if output.status.success() {
        return Ok(());
    }
    Err(format!(
        "git {} failed: {}",
        args.join(" "),
        String::from_utf8_lossy(&output.stderr).trim()
    ))
}

/// The two built binaries.
#[derive(Debug, Clone)]
struct Built {
    cli: PathBuf,
    daemon: PathBuf,
}

/// Build both halves in release with QuickRust's cargo, from the checkout's
/// cargo workspace, and ask QuickRust where it put them.
///
/// QuickRust's tools are the ones beside the installed pair: `qrust install`
/// put the pair there, and its `cargo` and `qrust` live there too. Not
/// `--locked`: the workspace's lock file is not committed, so a fresh checkout
/// has none to hold to.
fn build(install_dir: &Path, worktree: &Path) -> Result<Built, String> {
    let tool = |name: &str| -> Result<PathBuf, String> {
        let path = install_dir.join(if cfg!(windows) { format!("{name}.exe") } else { name.to_string() });
        if path.is_file() {
            return Ok(path);
        }
        Err(format!(
            "QuickRust's {name} is not in {}, beside the installed pair. wsb is installed and \
             rebuilt with QuickRust; `qrust setup` puts it there.",
            install_dir.display()
        ))
    };
    let cargo = tool("cargo")?;
    let qrust = tool("qrust")?;

    let arguments = ["build", "--release", "-p", "wsb", "-p", "wsb-daemon"];
    let status = crate::git_backend::command_without_repo_scope(&cargo)
        .args(arguments)
        .current_dir(worktree)
        .status()
        .map_err(|e| format!("cannot run {}: {e}", cargo.display()))?;
    if !status.success() {
        return Err(format!(
            "building wsb failed (cargo {}). Nothing installed has been touched.",
            arguments.join(" ")
        ));
    }

    let built = |half: Half| -> Result<PathBuf, String> {
        let name = match half {
            Half::Cli => "wsb",
            Half::Daemon => "wsb-daemon",
        };
        let output = crate::git_backend::command_without_repo_scope(&qrust)
            .args(["path", "bin", name, "--release"])
            .current_dir(worktree)
            .output()
            .map_err(|e| format!("cannot run {}: {e}", qrust.display()))?;
        if !output.status.success() {
            return Err(format!(
                "qrust could not say where {name} was built: {}",
                String::from_utf8_lossy(&output.stderr).trim()
            ));
        }
        let path = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim());
        if !path.is_file() {
            return Err(format!(
                "cargo built wsb, and {} — where qrust says {name} is — is not there. Nothing \
                 installed has been touched.",
                path.display()
            ));
        }
        Ok(path)
    };
    Ok(Built {
        cli: built(Half::Cli)?,
        daemon: built(Half::Daemon)?,
    })
}

/// Run a binary with `--version` and return the version it reports.
///
/// The step between "it compiled" and "it is installed": a binary that cannot
/// start — a missing shared library, the wrong architecture, a panic before
/// main — compiles perfectly well, and installing one leaves no working wsb to
/// undo it with. It must start, exit cleanly, and name itself: `<name>
/// <version>`, the name being the half it is meant to be.
fn started_version(binary: &Path, half: Half) -> Result<String, String> {
    let output = crate::git_backend::command_without_repo_scope(binary)
        .arg("--version")
        .output()
        .map_err(|e| format!("{} cannot be started: {e}", binary.display()))?;
    if !output.status.success() {
        return Err(format!(
            "{} did not start cleanly ({}): {}",
            binary.display(),
            output.status.code().map_or("killed by a signal".to_string(), |code| format!("exit {code}")),
            String::from_utf8_lossy(&output.stderr).trim()
        ));
    }
    let said = String::from_utf8_lossy(&output.stdout);
    let name = Half::file_name(half).trim_end_matches(".exe");
    let mut words = said.split_whitespace();
    match (words.next(), words.next(), words.next()) {
        (Some(named), Some(version), None) if named == name => Ok(version.to_string()),
        _ => Err(format!(
            "{} does not report itself as {name}: `--version` said {:?}",
            binary.display(),
            said.trim()
        )),
    }
}

/// Start both built binaries and, unless this is a dry run, put them in place
/// of the installed pair: both, or neither.
///
/// Returns the version they report and the paths replaced — or, on a dry run,
/// the paths that would be.
fn replace(built: &Built, install_dir: &Path, dry_run: bool) -> Result<(String, Vec<PathBuf>), String> {
    let not_installed = |why: String| format!("{why}. Nothing was replaced.");
    let cli_version = started_version(&built.cli, Half::Cli).map_err(not_installed)?;
    let daemon_version = started_version(&built.daemon, Half::Daemon).map_err(not_installed)?;
    if cli_version != daemon_version {
        return Err(not_installed(format!(
            "the built wsb is {cli_version} and the built wsb-daemon is {daemon_version}: not one \
             build, and the pair shares a database schema"
        )));
    }

    let pairs = [(Half::Cli, &built.cli), (Half::Daemon, &built.daemon)];
    let destinations: Vec<PathBuf> = pairs.iter().map(|(half, _)| install_dir.join(half.file_name())).collect();
    if dry_run {
        return Ok((cli_version, destinations));
    }

    // Both staged before either is renamed, so the window in which the pair
    // could be mismatched is two renames long instead of a copy long.
    let mut staged: Vec<PathBuf> = Vec::new();
    for (half, source) in pairs {
        match stage(source, install_dir, half.file_name()) {
            Ok(temporary) => staged.push(temporary),
            Err(e) => {
                for temporary in &staged {
                    let _ = std::fs::remove_file(temporary);
                }
                return Err(not_installed(e));
            }
        }
    }

    let mut replaced: Vec<PathBuf> = Vec::new();
    for (index, (temporary, destination)) in staged.iter().zip(&destinations).enumerate() {
        if let Err(e) = std::fs::rename(temporary, destination) {
            for left in &staged[index..] {
                let _ = std::fs::remove_file(left);
            }
            return Err(if replaced.is_empty() {
                format!("cannot put the new binary at {}: {e}. Nothing was replaced.", destination.display())
            } else {
                format!(
                    "cannot put the new binary at {}: {e}.\n  \
                     {} was ALREADY replaced, so the pair is mismatched — wsb and its daemon \
                     share a database schema and must be one build.\n  \
                     Run `wsb upgrade` again, or reinstall both, before starting a daemon.",
                    destination.display(),
                    replaced.iter().map(|p| p.display().to_string()).collect::<Vec<_>>().join(", ")
                )
            });
        }
        replaced.push(destination.clone());
    }
    Ok((cli_version, replaced))
}

/// Copy a built binary next to where it will live, ready to be renamed over.
fn stage(built: &Path, install_dir: &Path, name: &str) -> Result<PathBuf, String> {
    let temporary = install_dir.join(format!(".{name}.upgrade-{}", std::process::id()));
    std::fs::copy(built, &temporary)
        .map_err(|e| format!("cannot stage {} into {}: {e}", built.display(), install_dir.display()))?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(&temporary, std::fs::Permissions::from_mode(0o755))
            .map_err(|e| format!("cannot make {} executable: {e}", temporary.display()))?;
    }
    Ok(temporary)
}

/// What became of the machine once the new pair was in place.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MachineStep {
    /// The old daemon was stopped, the machine's state migrated, and the
    /// service reinstalled by the new wsb and started on the new daemon.
    Restarted { worktree: PathBuf },
    /// No service is installed. A running daemon was stopped and the
    /// machine's state migrated; the next command starts the new daemon.
    Stopped,
}

/// Bring the machine onto the new pair, as the NEW `wsb`'s own commands:
///
///   1. `daemon stop`: the migration refuses to run beside a daemon — it
///      moves the database that daemon has open — and a deliberate stop is
///      not one a supervisor restarts;
///   2. `migrate`, in `here`: the machine's state, every workspace, and the
///      checkout `here` is in when it is outside the workshop;
///   3. with a service, read only now that the migration has brought its
///      record over: `daemon install` with the ports it was installed with —
///      the definition names the executable, the arguments and the
///      environment, and how those are rendered is the new build's to say —
///      then `daemon restart`, in the worktree it was installed for, so the
///      workbench stays where every bookmark has it.
pub fn bring_the_machine_over(install_dir: &Path, here: &Path) -> Result<MachineStep, String> {
    let wsb = install_dir.join(Half::Cli.file_name());
    installed_wsb(&wsb, here, &["daemon", "stop"])?;
    installed_wsb(&wsb, here, &["migrate"])?;
    let state_dir = crate::daemon::machine::machine_dir()?;
    let Some(record) = crate::daemon::service::record(&state_dir)? else {
        return Ok(MachineStep::Stopped);
    };
    let port = recorded(&record.arguments, "--port")?;
    let peer_port = recorded(&record.arguments, "--peer-port")?;
    for arguments in [
        vec!["daemon", "install", "--port", port, "--peer-port", peer_port],
        vec!["daemon", "restart"],
    ] {
        installed_wsb(&wsb, &record.worktree_root, &arguments)?;
    }
    Ok(MachineStep::Restarted {
        worktree: record.worktree_root,
    })
}

/// The value of `flag` in the arguments a service was installed with.
fn recorded<'a>(arguments: &'a [String], flag: &str) -> Result<&'a str, String> {
    arguments
        .iter()
        .position(|argument| argument == flag)
        .and_then(|at| arguments.get(at + 1))
        .map(String::as_str)
        .ok_or_else(|| {
            format!(
                "the service record's arguments have no {flag}, so the service cannot be \
                 reinstalled on the port it had: {arguments:?}"
            )
        })
}

/// Run the installed wsb in `directory`, its output relayed as this run's.
fn installed_wsb(wsb: &Path, directory: &Path, arguments: &[&str]) -> Result<(), String> {
    let output = crate::git_backend::command_without_repo_scope(wsb)
        .args(arguments)
        .current_dir(directory)
        .output()
        .map_err(|e| format!("cannot run {}: {e}", wsb.display()))?;
    for line in String::from_utf8_lossy(&output.stdout).lines() {
        crate::outln!("  {line}");
    }
    for line in String::from_utf8_lossy(&output.stderr).lines() {
        crate::errln!("  {line}");
    }
    if output.status.success() {
        return Ok(());
    }
    Err(format!(
        "the new pair is installed, and `wsb {}` failed in {}; run it again once what it said is \
         fixed — the machine is not on the new pair until it has run",
        arguments.join(" "),
        directory.display()
    ))
}

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