wsb 0.258.6441

Workspace tools for every project under $PRJ: git across submodules, template stamping, GitHub, and the workbench
Documentation
//! The machine's own state: one directory, one rendezvous, worktree names,
//! and continuity identities two machines derive the same way without
//! talking.

use super::*;

/// TEST9964: the machine directory is one place, and the tests can move it.
///
/// The override is not a convenience: a suite that wrote to the developer's
/// real `~/.wsb` would publish a rendezvous over the running daemon's, and the
/// next command in that terminal would try to speak to a test fixture.
#[test]
fn test9964_the_machine_directory_is_overridable_for_tests() {
    let temp = tempfile::tempdir().expect("tempdir");
    temp_env(temp.path(), || {
        let dir = machine_dir().expect("a machine directory");
        assert_eq!(dir, temp.path());
        assert!(dir.is_dir(), "it is created rather than merely named");

        let staging = staging_dir().expect("a staging directory");
        assert!(staging.starts_with(&dir), "staging lives under the machine directory");
        assert!(
            staging.is_dir(),
            "incoming transfers need somewhere to land before they are applied"
        );
    });
}

/// TEST10002: a rendezvous from another build is not a rendezvous this one can
/// use — and not a reason to refuse every command.
///
/// The two halves refuse to speak across builds anyway, so a file this build
/// cannot read describes a daemon it could not have used. Treating that as a
/// corrupt file made every command fail, including the `wsb daemon restart`
/// that replaces it, over a machine-local file that was merely older than the
/// binary reading it.
#[test]
fn test10002_a_rendezvous_from_another_build_reads_as_no_daemon() {
    let dir = tempfile::tempdir().expect("a scratch directory");

    // What an older build published: the shape, minus a field this one added.
    std::fs::write(
        MachineRendezvous::path_in(dir.path()),
        serde_json::json!({
            "version": "1.110.1396",
            "pid": 4242,
            "http_address": "127.0.0.1",
            "http_port": 3000,
            "machine_id": "y18th0mn4cq8wp01",
            "machine_name": "bahram-rog",
            "peer_port": 7420,
            "started_at": 1787052781u64,
        })
        .to_string(),
    )
    .expect("write");

    assert_eq!(
        MachineRendezvous::read(dir.path()).expect("a file from another build is not an error"),
        None,
        "a rendezvous this build cannot read must read as no usable daemon"
    );

    // Something that is NOT this system's file still is an error: writing a
    // daemon's rendezvous over whatever that is compounds it.
    std::fs::write(MachineRendezvous::path_in(dir.path()), "not json at all")
        .expect("write");
    let error = MachineRendezvous::read(dir.path())
        .expect_err("a file that is not a rendezvous must be refused");
    assert!(error.contains("is not a daemon rendezvous file"), "{error}");

    // And one this build DOES understand round-trips.
    let mine = MachineRendezvous {
        version: crate::get_version().to_string(),
        pid: 99,
        http_address: std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
        http_port: 3000,
        machine_id: "k7f3q9zx2m".to_string(),
        machine_name: "desktop".to_string(),
        peer_address: std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED),
        peer_port: 7420,
        started_at: 1,
        control_token: String::new(),
    };
    mine.write(dir.path()).expect("publish");
    assert_eq!(MachineRendezvous::read(dir.path()).expect("read"), Some(mine));
}

/// TEST9965: the rendezvous is published whole, read back, and removed.
#[test]
fn test9965_the_rendezvous_round_trips_and_is_private() {
    let temp = tempfile::tempdir().expect("tempdir");
    let dir = temp.path();

    assert_eq!(MachineRendezvous::read(dir).expect("absent is not an error"), None);

    let published = MachineRendezvous {
        version: "1.2.3".into(),
        pid: 4242,
        http_address: std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
        http_port: 3000,
        machine_id: "k7f3q9zx2m".into(),
        machine_name: "desktop".into(),
        peer_address: std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED),
        peer_port: 7420,
        started_at: 1_700_000_000,
        control_token: String::new(),
    };
    published.write(dir).expect("published");
    assert_eq!(
        MachineRendezvous::read(dir).expect("read").as_ref(),
        Some(&published)
    );

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let mode = std::fs::metadata(MachineRendezvous::path_in(dir))
            .expect("metadata")
            .permissions()
            .mode();
        assert_eq!(mode & 0o077, 0, "the rendezvous is readable by others: {mode:o}");
    }

    // Removed only on behalf of the daemon it names: another daemon's file
    // is left where it is.
    assert!(!MachineRendezvous::remove_if_published_by(dir, 4243).expect("asked"), "another daemon's file was removed");
    assert_eq!(MachineRendezvous::read(dir).expect("read").as_ref(), Some(&published));
    assert!(MachineRendezvous::remove_if_published_by(dir, 4242).expect("removed"));
    assert_eq!(MachineRendezvous::read(dir).expect("read"), None);
    assert!(!MachineRendezvous::remove_if_published_by(dir, 4242).expect("removing an absent one is success"));

    // Something else's file at that path is an error, not a reason to write
    // over it.
    std::fs::write(MachineRendezvous::path_in(dir), b"not a rendezvous").expect("write");
    let error = MachineRendezvous::read(dir).expect_err("a foreign file must be refused");
    assert!(error.contains("not a daemon rendezvous"), "{error}");
}

/// TEST9966: two clones of one repository derive the same family, two
/// worktrees of it two continuity ids, and two repositories two families.
///
/// This is what makes pairing work without configuration: neither machine
/// tells the other what to call the worktree, and neither compares paths. A
/// machine's `main` is carried with the other's `main`, its `fix` with the
/// other's `fix` — never one worktree into another because they share a
/// repository.
#[test]
fn test9966_continuity_identity_is_the_family_and_the_worktree_name() {
    let temp = tempfile::tempdir().expect("tempdir");
    let origin = temp.path().join("origin");
    make_repo(&origin, "workspace one");

    let desktop = temp.path().join("desktop/main-worktree");
    let laptop = temp.path().join("mnt/code/main-worktree");
    for clone in [&desktop, &laptop] {
        std::fs::create_dir_all(clone.parent().expect("parent")).expect("mkdir");
        clone_into(&origin, clone);
    }
    let fix = temp.path().join("desktop/fix-worktree");
    run(&desktop, &["worktree", "add", "-q", "-b", "fix", fix.to_str().expect("path")]);

    let identity = |root: &std::path::Path| {
        identify_workspace(&place_worktree_in(None, root).expect("placed")).expect("identified")
    };
    let here = identity(&desktop);
    let there = identity(&laptop);
    let fixing = identity(&fix);

    assert_eq!(
        here.workspace, there.workspace,
        "the same worktree name of one repository at two paths is one identity"
    );
    assert_eq!(here.root_commit, there.root_commit);
    let (here_id, fix_id) = (here.workspace.expect("an id"), fixing.workspace.expect("an id"));
    assert_eq!(here_id.family(), fix_id.family(), "one repository, one family");
    assert_ne!(here_id, fix_id, "two worktrees of it are two identities");
    assert_eq!(here_id.worktree(), "main-worktree");
    assert_eq!(fix_id.worktree(), "fix-worktree");

    let other = temp.path().join("other");
    make_repo(&other, "workspace two");
    let other_id = identity(&other).workspace.expect("an id");
    assert_ne!(here_id.family(), other_id.family(), "two repositories, two families");

    // A directory that is not a repository has no identity, and says so rather
    // than inventing one.
    let plain = temp.path().join("plain");
    std::fs::create_dir_all(&plain).expect("mkdir");
    let error = identify_workspace(&place_worktree_in(None, &plain).expect("placed"))
        .expect_err("not a repository");
    assert!(error.contains("not a git repository"), "{error}");

    // A repository with no commits is a worktree with no continuity: there is
    // no base to synchronise against, and an id derived from nothing would
    // collide with every other empty repository on both machines.
    let unborn = temp.path().join("unborn");
    std::fs::create_dir_all(&unborn).expect("mkdir");
    run(&unborn, &["init", "-q", "-b", "main"]);
    let nothing = identity(&unborn);
    assert_eq!(nothing.root_commit, None);
    assert!(nothing.workspace.expect_err("no id").contains("no commits"));

    // A folder name two machines could not share is refused for continuity,
    // and the worktree is still a worktree here.
    let spaced = temp.path().join("my notes");
    make_repo(&spaced, "spaced");
    let unshareable = identity(&spaced);
    assert!(unshareable.root_commit.is_some());
    assert!(unshareable.workspace.is_err(), "'my notes' cannot name a shared worktree");
}

/// TEST20030: a worktree is named where `prj` keeps it, and anything else by
/// its path.
///
/// Every worktree of a project was named by the repository it checks out, so
/// the first one opened answered for all of them. Inside `$PRJ` the name is
/// `prj`'s — `<project>:<worktree>`, `main` for `main-worktree` — and a
/// repository elsewhere in a project folder, or outside `$PRJ`, is named by
/// a hash of its canonical path, never by a folder name another checkout
/// could share.
#[test]
fn test20030_a_worktree_is_named_by_its_place() {
    let temp = tempfile::tempdir().expect("tempdir");
    let root = crate::platform::canonicalize(temp.path()).expect("canonical");
    let main = root.join("alpha/main-worktree");
    make_repo(&main, "alpha");
    let fix = root.join("alpha/fix-worktree");
    run(&main, &["worktree", "add", "-q", "-b", "fix", fix.to_str().expect("path")]);
    // A repository inside a worktree folder, and not that worktree: its
    // place under $PRJ says alpha/fix, and it is not alpha:fix.
    let stray = root.join("alpha/fix-worktree/vendor/copy");
    std::fs::create_dir_all(stray.parent().unwrap()).expect("mkdir");
    clone_into(&main, &stray);
    let prj = wsb_prj::Prj::at(&root).expect("a PRJ");

    let placed = place_worktree_in(Some(&prj), &main).expect("placed");
    assert_eq!(placed.id.as_str(), "alpha:main");
    assert_eq!(placed.project.as_deref(), Some("alpha"));
    assert_eq!(placed.name, "main");
    assert_eq!(placed.id.in_project(), Some(("alpha", "main")));

    let other = place_worktree_in(Some(&prj), &fix).expect("placed");
    assert_eq!(other.id.as_str(), "alpha:fix");
    assert_ne!(placed.id, other.id, "two worktrees of one repository are two worktrees");

    let loose = place_worktree_in(Some(&prj), &stray).expect("placed");
    assert_eq!(loose.project, None, "a clone in a project folder is not one of its worktrees");
    assert!(loose.id.as_str().starts_with('~'));
    assert_eq!(loose.id.in_project(), None);
    assert_eq!(
        place_worktree_in(None, &stray).expect("placed").id,
        loose.id,
        "a path-named worktree is named the same with or without PRJ"
    );
    assert_ne!(place_worktree_in(None, &main).expect("placed").id, placed.id);

    for good in ["alpha:main", "alpha:fix-2", "~0123456789abcdef"] {
        assert_eq!(WorktreeId::parse(good).expect(good).as_str(), good);
    }
    for bad in ["alpha", ":main", "alpha:", "~0123", "~0123456789ABCDEF", "a b:main", "alpha:main:x"] {
        assert!(WorktreeId::parse(bad).is_err(), "{bad:?} was taken as a worktree id");
    }
}

/// TEST20031: the machine's old `~/.wsb` moves into the machine directory
/// once, keeping its identity and dropping the plaintext token.
///
/// The identity and the database are what paired machines know this one by,
/// so they move as they are; the settings move without the `[github]` section
/// that held a token in plain text. A stray file, a copy already at the
/// destination, or an old daemon still running stops it before anything
/// moves, naming the cause. A move a process died part way through is
/// finished by the next one.
#[test]
fn test20031_the_old_machine_directory_is_adopted_once() {
    let temp = tempfile::tempdir().expect("tempdir");
    let home = temp.path().join("home");
    let old = home.join(".wsb");
    let machine = temp.path().join("prj/.state/hosts/h0123456789ab");
    let seed = |extra: &[(&str, &str)]| {
        std::fs::create_dir_all(old.join("logs")).expect("mkdir");
        std::fs::write(old.join("machine-identity.json"), "identity").expect("write");
        std::fs::write(old.join("machine.db"), "peers").expect("write");
        std::fs::write(old.join("logs/a.log"), "old log").expect("write");
        std::fs::create_dir_all(old.join("disposable/logs/ops")).expect("mkdir");
        std::fs::write(old.join("disposable/logs/ops/run.jsonl"), "{}").expect("write");
        std::fs::write(
            old.join("config.toml"),
            "[github]\ntoken = \"ghp_secret\"\n\n[ui]\ntheme = \"dark\"\n",
        )
        .expect("write");
        for (name, text) in extra {
            std::fs::write(old.join(name), text).expect("write");
        }
    };

    seed(&[("stray.txt", "mine")]);
    let error = adopt_old_machine_dir(&home, &machine).expect_err("a stray file stops it");
    assert!(error.contains("stray.txt"), "{error}");
    assert!(old.join("machine-identity.json").exists(), "nothing moved before the refusal");
    std::fs::remove_file(old.join("stray.txt")).expect("rm");

    std::fs::create_dir_all(&machine).expect("mkdir");
    std::fs::write(machine.join("machine.db"), "other peers").expect("write");
    let error = adopt_old_machine_dir(&home, &machine).expect_err("two databases stop it");
    assert!(error.contains("both exist"), "{error}");
    std::fs::remove_file(machine.join("machine.db")).expect("rm");

    let done = adopt_old_machine_dir(&home, &machine).expect("adopted");
    assert!(!old.exists(), "the old directory is gone");
    assert_eq!(std::fs::read_to_string(machine.join("machine-identity.json")).unwrap(), "identity");
    assert_eq!(std::fs::read_to_string(machine.join("machine.db")).unwrap(), "peers");
    assert!(!machine.join("logs").exists(), "old logs are not carried");
    assert!(!machine.join("disposable").exists(), "nor are logs an older wsb filed here");
    let settings = std::fs::read_to_string(machine.join("config.toml")).unwrap();
    assert!(!settings.contains("ghp_secret"), "the token was carried: {settings}");
    assert!(settings.contains("theme"), "the rest of the settings were lost: {settings}");
    assert!(done.iter().any(|line| line.contains("without its unread [github]")), "{done:?}");
    assert!(adopt_old_machine_dir(&home, &machine).expect("nothing to do").is_empty());

    // A mover that died part way: its claim is finished by the next process.
    let dead = home.join(format!("{CLAIM_PREFIX}{}", dead_pid()));
    std::fs::create_dir_all(&dead).expect("mkdir");
    std::fs::write(dead.join("plugins.json"), "[]").expect("write");
    adopt_old_machine_dir(&home, &machine).expect("finished");
    assert!(!dead.exists());
    assert_eq!(std::fs::read_to_string(machine.join("plugins.json")).unwrap(), "[]");
}

/// A pid no process has.
fn dead_pid() -> u32 {
    (4_000_000..u32::MAX)
        .find(|pid| !crate::ops::process::is_alive(*pid))
        .expect("a pid nobody holds")
}

fn clone_into(origin: &std::path::Path, clone: &std::path::Path) {
    let output = crate::git_backend::git_command()
        .args(["clone", "-q", origin.to_str().expect("path"), clone.to_str().expect("path")])
        .output()
        .expect("clone");
    assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
}

fn make_repo(dir: &std::path::Path, content: &str) {
    std::fs::create_dir_all(dir).expect("mkdir");
    run(dir, &["init", "-q", "-b", "main"]);
    run(dir, &["config", "user.name", "Test"]);
    run(dir, &["config", "user.email", "test@example.com"]);
    std::fs::write(dir.join("README.md"), format!("{content}\n")).expect("write");
    run(dir, &["add", "."]);
    run(dir, &["commit", "-qm", "one"]);
}

fn run(dir: &std::path::Path, args: &[&str]) {
    let output = crate::git_backend::git_command()
        .args(args)
        .current_dir(dir)
        .output()
        .unwrap_or_else(|e| panic!("git {args:?}: {e}"));
    assert!(
        output.status.success(),
        "git {args:?}: {}",
        String::from_utf8_lossy(&output.stderr)
    );
}

/// Run with the machine directory pointed at a temporary one.
///
/// The variable is process-wide, so this serialises: two tests changing it at
/// once would each see the other's directory.
fn temp_env<T>(dir: &std::path::Path, body: impl FnOnce() -> T) -> T {
    static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
    let _guard = LOCK.lock().unwrap_or_else(|e| e.into_inner());
    let previous = std::env::var_os(MACHINE_DIR_ENV);
    std::env::set_var(MACHINE_DIR_ENV, dir);
    let outcome = body();
    match previous {
        Some(value) => std::env::set_var(MACHINE_DIR_ENV, value),
        None => std::env::remove_var(MACHINE_DIR_ENV),
    }
    outcome
}