wsb 0.291.8591

Workspace tools for every project under $PRJ: git across submodules, template stamping, GitHub, and the workbench
Documentation
use super::*;
use crate::test_environment::Environment;
use std::path::PathBuf;

/// A workshop whose project `demo` has just been moved to `demo2`, with what
/// wsb kept for `demo/main` still under the old name, and a project
/// `demo-api` whose name starts with the old one.
struct Moved {
    workshop: wsb_prj::Workshop,
    root: PathBuf,
    host: tempfile::TempDir,
    _prj: tempfile::TempDir,
    _env: Environment,
}

fn moved() -> Moved {
    let prj = tempfile::tempdir().unwrap();
    let host = tempfile::tempdir().unwrap();
    let root = crate::platform::canonicalize(prj.path()).unwrap();
    let env = Environment::set(&[
        ("PRJ", &root.to_string_lossy()),
        (crate::daemon::HOST_DIR_ENV, &host.path().to_string_lossy()),
    ]);
    for project in ["demo2", "demo-api"] {
        std::fs::create_dir_all(root.join(project).join(wsb_prj::MAIN_DIR)).unwrap();
    }
    let old_state = root.join(".state/workspaces/demo/worktrees/main");
    std::fs::create_dir_all(old_state.join("logs")).unwrap();
    std::fs::write(old_state.join("logs/run.jsonl"), "{}\n").unwrap();

    let store = crate::store::open().unwrap();
    let db = store.get_connection().unwrap();
    let old_dir = root.join("demo/main-worktree").to_string_lossy().into_owned();
    for worktree in ["demo/main", "demo-api/main"] {
        db.execute(
            "INSERT INTO tests (worktree, repo, language, function_name, file_path, line_number)
             VALUES (?1, '.', 'rust', 'test1_x', 'src/lib.rs', 1)",
            [worktree],
        )
        .unwrap();
        db.execute(
            "INSERT INTO workbench_state (worktree, state_json) VALUES (?1, '{\"expanded_paths\":[\"src\"]}')",
            [worktree],
        )
        .unwrap();
    }
    db.execute(
        "INSERT INTO worktrees (worktree_id, project, name, shared_worktree, super_root, last_seen)
         VALUES ('demo:main', 'demo', 'main', 'aaaa@main', ?1, 0),
                ('demo-api:main', 'demo-api', 'main', 'bbbb@main', '/elsewhere', 0)",
        [&old_dir],
    )
    .unwrap();
    db.execute(
        "INSERT INTO sessions (id, project, worktree, agent, cwd) VALUES
             ('s1', 'demo', 'main', 'claude', ?1 || '/src'),
             ('s2', 'demo-api', 'main', 'claude', '/elsewhere')",
        [&old_dir],
    )
    .unwrap();
    let layout = serde_json::json!({ "workbench": { "groups": [{
        "id": "group:1",
        "tabs": [
            { "key": "file:demo:main|src/lib.rs", "input": { "kind": "file", "worktree": "demo:main", "path": "src/lib.rs" } },
            { "key": "project:demo", "input": { "kind": "project", "project": "demo" } },
            { "key": "overview:demo-api:main", "input": { "kind": "overview", "worktree": "demo-api:main" } },
        ],
        "active": "file:demo:main|src/lib.rs",
    }]}});
    db.execute(
        "INSERT INTO workbench_layout (id, layout_json, updated_at) VALUES (1, ?1, '')",
        [layout.to_string()],
    )
    .unwrap();

    Moved { workshop: wsb_prj::Workshop::at(&root).unwrap(), root, host, _prj: prj, _env: env }
}

fn rows(sql: &str) -> Vec<String> {
    let store = crate::store::open().unwrap();
    let db = store.get_connection().unwrap();
    let mut statement = db.prepare(sql).unwrap();
    let mut found: Vec<String> = statement.query_map([], |row| row.get(0)).unwrap().map(Result::unwrap).collect();
    found.sort();
    found
}

/// TEST20212: a renamed project's worktrees find their local state, test
/// catalogue, open folders, sessions and workbench tabs under the new name,
/// nothing is left under the old one, and a project whose name only starts
/// with the old one is left alone. Run again, it has nothing to do.
#[test]
fn test20212_what_wsb_keeps_follows_a_renamed_project() {
    let moved = moved();
    let done = rename(&moved.workshop, "demo", "demo2").unwrap();

    let state = moved.root.join(".state/workspaces");
    assert_eq!(std::fs::read_to_string(state.join("demo2/worktrees/main/logs/run.jsonl")).unwrap(), "{}\n");
    assert!(!state.join("demo").exists(), "{done:?}");

    assert_eq!(rows("SELECT worktree FROM tests"), ["demo-api/main", "demo2/main"]);
    assert_eq!(rows("SELECT worktree FROM workbench_state"), ["demo-api/main", "demo2/main"]);
    assert_eq!(rows("SELECT worktree_id || ' ' || project FROM worktrees"), ["demo-api:main demo-api", "demo2:main demo2"]);
    let new_dir = moved.root.join("demo2/main-worktree").to_string_lossy().into_owned();
    assert_eq!(rows("SELECT super_root FROM worktrees WHERE project = 'demo2'"), [new_dir.clone()]);
    assert_eq!(rows("SELECT project || ' ' || cwd FROM sessions WHERE id = 's1'"), [format!("demo2 {new_dir}/src")]);
    assert_eq!(rows("SELECT project FROM sessions WHERE id = 's2'"), ["demo-api"]);

    let layout: serde_json::Value =
        serde_json::from_str(&rows("SELECT layout_json FROM workbench_layout")[0]).unwrap();
    let group = &layout["workbench"]["groups"][0];
    assert_eq!(group["tabs"][0]["key"], "file:demo2:main|src/lib.rs");
    assert_eq!(group["tabs"][0]["input"]["worktree"], "demo2:main");
    assert_eq!(group["tabs"][1]["key"], "project:demo2");
    assert_eq!(group["tabs"][1]["input"]["project"], "demo2");
    assert_eq!(group["tabs"][2]["key"], "overview:demo-api:main");
    assert_eq!(group["active"], "file:demo2:main|src/lib.rs");

    assert_eq!(rename(&moved.workshop, "demo", "demo2").unwrap(), ["nothing is kept under demo"]);
}

/// TEST20213: what the new name already has is kept beside what moves to it,
/// and a row it has for the same worktree gives way to the old name's.
#[test]
fn test20213_what_ran_under_the_new_name_first_is_merged() {
    let moved = moved();
    let ahead = moved.root.join(".state/workspaces/demo2/worktrees/main/logs");
    std::fs::create_dir_all(&ahead).unwrap();
    std::fs::write(ahead.join("since.jsonl"), "{}\n").unwrap();
    {
        let store = crate::store::open().unwrap();
        let db = store.get_connection().unwrap();
        db.execute("INSERT INTO workbench_state (worktree, state_json) VALUES ('demo2/main', '{\"expanded_paths\":[]}')", [])
            .unwrap();
    }

    rename(&moved.workshop, "demo", "demo2").unwrap();
    let logs = moved.root.join(".state/workspaces/demo2/worktrees/main/logs");
    assert!(logs.join("since.jsonl").is_file() && logs.join("run.jsonl").is_file());
    assert_eq!(
        rows("SELECT state_json FROM workbench_state WHERE worktree = 'demo2/main'"),
        ["{\"expanded_paths\":[\"src\"]}"]
    );
}

/// TEST20214: refused, with nothing moved, while the old folder is still a
/// project, while a daemon runs, and while something is at work in one of
/// the old name's worktrees.
#[test]
fn test20214_a_rename_waits_for_the_folder_the_daemon_and_the_writers() {
    let moved = moved();
    let untouched = || moved.root.join(".state/workspaces/demo/worktrees/main/logs/run.jsonl").is_file();

    std::fs::create_dir_all(moved.root.join("demo/main-worktree")).unwrap();
    let refused = rename(&moved.workshop, "demo", "demo2").unwrap_err().to_string();
    assert!(refused.contains("move its folder"), "{refused}");
    std::fs::remove_dir_all(moved.root.join("demo")).unwrap();

    let daemon = moved.host.path().join("daemon.json");
    std::fs::write(&daemon, format!("{{\"pid\":{}}}", std::process::id())).unwrap();
    let refused = rename(&moved.workshop, "demo", "demo2").unwrap_err().to_string();
    assert!(refused.contains("wsb project rename demo demo2"), "{refused}");
    std::fs::remove_file(&daemon).unwrap();

    let writers = moved.root.join(".state/workspaces/demo/worktrees/main").join(crate::local_state::WRITERS_DIR);
    std::fs::create_dir_all(&writers).unwrap();
    let holder = crate::worktree_lock::Holder {
        command: "wsb checkpoint".into(),
        argv: Vec::new(),
        pid: std::process::id(),
        host: crate::worktree_lock::host_name(),
        taken_at: String::new(),
        reason: "committing".into(),
        protects: String::new(),
    };
    std::fs::write(writers.join("me.json"), serde_json::to_string(&holder).unwrap()).unwrap();
    let refused = rename(&moved.workshop, "demo", "demo2").unwrap_err().to_string();
    assert!(refused.contains("wsb checkpoint") && refused.contains("at work"), "{refused}");
    assert!(untouched());
    assert_eq!(rows("SELECT worktree FROM tests WHERE worktree = 'demo/main'"), ["demo/main"]);
}