use super::*;
use crate::test_environment::Environment;
fn git(dir: &Path, args: &[&str]) {
let output = crate::git_backend::git_command()
.arg("-C")
.arg(dir)
.args(["-c", "user.name=t", "-c", "user.email=t@t"])
.args(args)
.output()
.expect("git runs");
assert!(output.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&output.stderr));
}
struct OldLayout {
workshop: wsb_prj::Workshop,
main: PathBuf,
machine: tempfile::TempDir,
_prj: tempfile::TempDir,
_env: Environment,
}
fn old_layout() -> OldLayout {
let prj = tempfile::tempdir().unwrap();
let machine = tempfile::tempdir().unwrap();
let root = crate::platform::canonicalize(prj.path()).unwrap();
let env = Environment::set(&[
("PRJ", &root.to_string_lossy()),
(crate::daemon::MACHINE_DIR_ENV, &machine.path().to_string_lossy()),
]);
let main = root.join("alpha/main-worktree");
std::fs::create_dir_all(&main).unwrap();
git(&main, &["init", "--quiet"]);
git(&main, &["remote", "add", "origin", "https://github.com/owner/alpha.git"]);
std::fs::write(main.join("version.txt"), "2.40.7\n").unwrap();
git(&main, &["add", "version.txt"]);
git(&main, &["commit", "--quiet", "-m", "first"]);
let dot = main.join(".wsb");
std::fs::create_dir_all(dot.join("disposable/logs/ops/2026-10-01")).unwrap();
std::fs::create_dir_all(dot.join("extensions")).unwrap();
std::fs::create_dir_all(dot.join("templates")).unwrap();
std::fs::write(
dot.join("state.json"),
r#"{"version":1,"version_suffix":"beta","project_name":"Alpha","tools":{},
"wstemplate_entries":[{"alias":"alpha_core","root":"."}],
"ignored_submodules":["vendor/big"],"completion_hint_shown":false}"#,
)
.unwrap();
std::fs::write(dot.join("disposable/logs/ops/2026-10-01/run.jsonl"), "{}\n").unwrap();
std::fs::write(dot.join("extensions/theme.vsix"), "zip").unwrap();
std::fs::write(dot.join("templates/templates.json"), "{}").unwrap();
let db = rusqlite::Connection::open(dot.join("wsb.db")).unwrap();
db.execute_batch(
"CREATE TABLE projects (id TEXT, major_version INTEGER, version_file TEXT,
auto_detect_project_files INTEGER, project_files TEXT);
INSERT INTO projects VALUES ('P001', 0, 'version.txt', 1, '[\"pkg/Cargo.toml\"]');
CREATE TABLE workbench_state (workspace_root TEXT PRIMARY KEY, state_json TEXT, updated_at TEXT);
CREATE TABLE notes (id TEXT);
INSERT INTO notes VALUES ('n1');",
)
.unwrap();
db.execute(
"INSERT INTO workbench_state VALUES (?1, '{\"groups\":[1],\"expanded_paths\":[\"src\"]}', '')",
[main.to_string_lossy()],
)
.unwrap();
drop(db);
let exclude = main.join(".git/info/exclude");
std::fs::write(&exclude, "*.swp\n.wsb/*\n!.wsb/stamp.json\n!.wsb/test.json\n!.wsb/peer.json\n").unwrap();
let old_mirror = wsb_github::Mirror::open(&root.join(".state/github.db")).unwrap();
old_mirror
.put_one(
&wsb_github::Scope::Viewer,
wsb_github::mirror::Document::of("viewer", serde_json::json!({ "login": "bahram" })),
)
.unwrap();
drop(old_mirror);
let peers = rusqlite::Connection::open(machine.path().join("machine.db")).unwrap();
peers
.execute_batch(
"CREATE TABLE peers (peer_id TEXT PRIMARY KEY NOT NULL, name TEXT NOT NULL, public_key TEXT NOT NULL,
fingerprint TEXT NOT NULL, address TEXT NOT NULL, paired_at INTEGER NOT NULL, last_seen INTEGER);
CREATE TABLE relationships (peer_id TEXT NOT NULL, workspace_id TEXT NOT NULL, desired TEXT NOT NULL,
enabled_at INTEGER NOT NULL, PRIMARY KEY (peer_id, workspace_id));
INSERT INTO peers VALUES ('p1', 'laptop', 'key', 'ff', '127.0.0.1:7420', 1, NULL);
INSERT INTO relationships VALUES ('p1', 'cccc3333@main', 'enabled', 2);",
)
.unwrap();
drop(peers);
let old_state = root.join(".state/projects/alpha");
std::fs::create_dir_all(old_state.join("snapshots")).unwrap();
std::fs::write(old_state.join("github.json"), r#"{"boards":["owner/7","owner/9"]}"#).unwrap();
std::fs::write(old_state.join("snapshots/held.json"), "{}").unwrap();
OldLayout {
workshop: wsb_prj::Workshop::at(&root).unwrap(),
main,
machine,
_prj: prj,
_env: env,
}
}
#[test]
fn test20113_an_old_workshop_is_moved_whole_and_once() {
let layout = old_layout();
let main = &layout.main;
assert!(!leftovers_in(main).unwrap().is_empty(), "the old layout was not seen");
let report = migrate_workshop(&layout.workshop).unwrap();
assert!(report.refused.is_empty(), "{:?}", report.refused);
let settings = RepoSettings::require(main).unwrap();
assert_eq!(settings.name, "Alpha");
assert_eq!(settings.alias, "alpha_core");
assert_eq!(settings.templates_root, ".");
assert_eq!(settings.version.major, 2, "a major of 0 in the database is the version file's, as it was");
assert_eq!(settings.version.suffix.as_deref(), Some("beta"));
assert_eq!(settings.version.manifests, vec!["pkg/Cargo.toml".to_string()]);
let project = ProjectSettings::load(main).unwrap();
assert_eq!(project.boards, vec!["owner/7".to_string(), "owner/9".to_string()]);
assert_eq!(project.ignored_submodules, vec!["vendor/big".to_string()]);
let state = crate::local_state::LocalState::of(main).unwrap();
let store = rusqlite::Connection::open(crate::store::path().unwrap()).unwrap();
let layout_json: String = store
.query_row("SELECT state_json FROM workbench_state WHERE worktree = 'alpha/main'", [], |row| row.get(0))
.unwrap();
assert_eq!(layout_json, "{\"expanded_paths\":[\"src\"]}", "the explorer's open folders were not kept, or the old tabs came with them");
let viewer: String = store
.query_row("SELECT payload_json FROM github_document WHERE scope = 'viewer'", [], |row| row.get(0))
.unwrap();
assert!(viewer.contains("bahram"), "the old mirror's rows were not brought in: {viewer}");
let peer: String = store.query_row("SELECT name FROM peers WHERE peer_id = 'p1'", [], |row| row.get(0)).unwrap();
assert_eq!(peer, "laptop", "the old peers were not brought in");
let (shared, mode): (String, String) = store
.query_row("SELECT shared_worktree, mode FROM relationships WHERE peer_id = 'p1'", [], |row| {
Ok((row.get(0)?, row.get(1)?))
})
.unwrap();
assert_eq!(shared, "cccc3333@main", "a relationship's worktree was not brought in under its new name");
assert_eq!(mode, "manual", "a relationship from before modes must not start carrying files by itself");
drop(store);
let mut databases = Vec::new();
for top in [layout.workshop.root(), layout.machine.path()] {
for entry in walkdir::WalkDir::new(top).into_iter().filter_map(|e| e.ok()) {
if entry.path().extension().is_some_and(|ext| ext == "db") {
databases.push(entry.path().to_path_buf());
}
}
}
assert_eq!(databases, vec![crate::store::path().unwrap()], "a database other than the workshop's is left");
assert!(state.logs().join("ops/2026-10-01/run.jsonl").is_file(), "the logs were not moved");
assert!(layout.machine.path().join("extensions/theme.vsix").is_file(), "the extension was not moved");
assert!(
report.done.iter().any(|line| line.contains("1 row(s) of notes")),
"dropping what is not kept was not said: {:?}",
report.done
);
let exclude = std::fs::read_to_string(main.join(".git/info/exclude")).unwrap();
assert_eq!(exclude, "*.swp\n", "the exclude still hides .wsb/, or lost somebody's line");
let mut left: Vec<String> = std::fs::read_dir(main.join(".wsb"))
.unwrap()
.map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
.collect();
left.sort();
assert_eq!(left, vec!["project.json", "repo.json"], "the checkout's .wsb/ holds more than committed settings");
assert!(leftovers_in(main).unwrap().is_empty());
let root = layout.workshop.root();
assert!(!root.join(".state/projects").exists(), "the old state folder was left");
assert!(root.join(".state/workspaces/alpha/snapshots/held.json").is_file(), "the snapshots were not moved");
assert!(!root.join(".state/workspaces/alpha/github.json").exists(), "the board list was moved, not folded");
let again = migrate_workshop(&layout.workshop).unwrap();
assert!(again.refused.is_empty() && again.done.is_empty(), "a second run did something: {again:?}");
}
#[test]
fn test20114_what_cannot_be_moved_without_guessing_is_refused() {
let layout = old_layout();
let main = &layout.main;
RepoSettings {
name: "Other".to_string(),
alias: "other".to_string(),
templates_root: ".".to_string(),
version: VersionSettings::at_major(9),
}
.save(main)
.unwrap();
std::fs::write(main.join(".wsb/templates/readme.tera"), "{{ x }}").unwrap();
let report = migrate_workshop(&layout.workshop).unwrap();
assert_eq!(report.refused.len(), 1, "{:?}", report.refused);
assert!(report.refused[0].starts_with("alpha/main:"), "{:?}", report.refused);
assert!(report.refused[0].contains("keep one"), "{:?}", report.refused);
assert!(main.join(".wsb/state.json").is_file(), "a refused move removed the state file");
assert_eq!(RepoSettings::require(main).unwrap().name, "Other", "a refused move rewrote the settings");
std::fs::remove_file(RepoSettings::path(main)).unwrap();
let report = migrate_workshop(&layout.workshop).unwrap();
assert_eq!(report.refused.len(), 1, "{:?}", report.refused);
assert!(report.refused[0].contains("readme.tera"), "{:?}", report.refused);
assert!(main.join(".wsb/templates/readme.tera").is_file());
}
#[test]
fn test20115_pins_become_committed_links() {
let layout = old_layout();
let db = rusqlite::Connection::open(layout.machine.path().join("wsb.db")).unwrap();
db.execute_batch(include_str!("../../migrations/V1__sessions.sql")).unwrap();
db.execute_batch(
"INSERT INTO sessions (id, project, worktree, agent) VALUES ('s1', 'alpha', 'main', 'claude');
INSERT INTO messages (id, session_id, ordinal, role, content, source_uuid)
VALUES (5, 's1', 0, 'user', 'hi', 'uuid-5');
INSERT INTO pinned_messages (session_id, message_id, ordinal, note) VALUES ('s1', 5, 0, 'why it broke');",
)
.unwrap();
db.execute_batch(
"CREATE TABLE refinery_schema_history (version INT4 PRIMARY KEY, name VARCHAR(255), \
applied_on VARCHAR(255), checksum VARCHAR(255));",
)
.unwrap();
let v1 = crate::persist::global::migrations::runner()
.get_migrations()
.iter()
.find(|migration| migration.version() == 1)
.map(|migration| (migration.name().to_string(), migration.checksum().to_string()))
.expect("the sessions schema has a V1");
db.execute(
"INSERT INTO refinery_schema_history VALUES (1, ?1, '2026-10-01T00:00:00Z', ?2)",
rusqlite::params![v1.0, v1.1],
)
.unwrap();
drop(db);
assert_eq!(crate::store::path().unwrap(), layout.machine.path().join("wsb.db"));
let repo = crate::persist::global::SessionRepo::with_pins_settled(crate::store::open_for_migration().unwrap())
.unwrap();
let held: i64 = repo
.get_connection()
.unwrap()
.query_row("SELECT COUNT(*) FROM pins_to_migrate", [], |row| row.get(0))
.unwrap();
assert_eq!(held, 1, "opening the database lost the pin it was meant to hold");
drop(repo);
let report = migrate_workshop(&layout.workshop).unwrap();
assert!(report.refused.is_empty(), "{:?}", report.refused);
let links = Link::all(&layout.main).unwrap();
let message: Ref = "message:claude/s1/uuid-5".parse().unwrap();
assert!(
links.iter().any(|link| link.from == message
&& link.to == "project:owner/alpha".parse().unwrap()
&& link.label.as_deref() == Some("pinned")),
"{links:?}"
);
let notes = Note::all(&layout.main).unwrap();
assert_eq!(notes.len(), 1);
assert_eq!(notes[0].body, "why it broke\n");
assert!(links.iter().any(|link| link.from == Ref::Note { id: notes[0].id.clone() } && link.to == message));
let gone: bool = rusqlite::Connection::open(layout.machine.path().join("wsb.db"))
.unwrap()
.query_row(
"SELECT NOT EXISTS (SELECT 1 FROM sqlite_master WHERE name = 'pins_to_migrate')",
[],
|row| row.get(0),
)
.unwrap();
assert!(gone, "the holding table outlived the migration");
}
fn old_sessions_in(host: &Path, session: &str) {
std::fs::create_dir_all(host).unwrap();
let db = rusqlite::Connection::open(host.join("wsb.db")).unwrap();
db.execute_batch(include_str!("../../migrations/V1__sessions.sql")).unwrap();
db.execute("INSERT INTO sessions (id, project, worktree, agent) VALUES (?1, 'alpha', 'main', 'claude')", [session])
.unwrap();
db.execute("INSERT INTO starred_sessions (session_id) VALUES (?1)", [session]).unwrap();
}
#[test]
fn test20120_the_sessions_database_becomes_the_workshops() {
for case in ["moved", "an empty store first", "a store with rows first"] {
let prj = tempfile::tempdir().unwrap();
let root = crate::platform::canonicalize(prj.path()).unwrap();
std::fs::create_dir_all(root.join("alpha/main-worktree")).unwrap();
let home = tempfile::tempdir().unwrap();
let home_key = if cfg!(windows) { "APPDATA" } else { "HOME" };
let _env = Environment::set(&[
("PRJ", &root.to_string_lossy()),
(crate::daemon::MACHINE_DIR_ENV, ""),
(home_key, &home.path().to_string_lossy()),
]);
let workshop = wsb_prj::Workshop::at(&root).unwrap();
let host = workshop.host_dir().unwrap();
let session = "s-old";
old_sessions_in(&host, session);
let store = root.join(".state/wsb.db");
assert_eq!(crate::store::path().unwrap(), store);
let refused = crate::store::open().expect_err("the database opened beside an older one");
assert!(refused.to_string().contains("wsb migrate"), "{refused}");
assert!(!store.exists(), "a refused open made the database");
std::fs::write(host.join("daemon.json"), format!("{{\"pid\": {}}}", std::process::id())).unwrap();
let refused = migrate_workshop(&workshop).expect_err("a running daemon's databases were moved");
assert!(refused.to_string().contains("wsb daemon stop"), "{refused}");
std::fs::remove_file(host.join("daemon.json")).unwrap();
if case != "moved" {
let made = crate::store::open_for_migration().unwrap();
if case == "a store with rows first" {
made.get_connection()
.unwrap()
.execute("INSERT INTO excluded_sessions (id) VALUES ('s-new')", [])
.unwrap();
let report = migrate_workshop(&workshop).unwrap();
assert_eq!(report.refused.len(), 1, "{:?}", report.refused);
assert!(report.refused[0].contains("both exist"), "{:?}", report.refused);
assert!(host.join("wsb.db").exists(), "a refused merge removed the old sessions");
continue;
}
}
let report = migrate_workshop(&workshop).unwrap();
assert!(report.refused.is_empty(), "{case}: {:?}", report.refused);
assert!(!host.join("wsb.db").exists(), "{case}: the old sessions database was left in the host folder");
let conn = rusqlite::Connection::open(&store).unwrap();
let starred: i64 = conn
.query_row("SELECT COUNT(*) FROM starred_sessions WHERE session_id = ?1", [session], |row| row.get(0))
.unwrap();
assert_eq!(starred, 1, "{case}: the star was lost");
let sessions: i64 =
conn.query_row("SELECT COUNT(*) FROM sessions WHERE id = ?1", [session], |row| row.get(0)).unwrap();
assert_eq!(sessions, 1, "{case}: the sessions did not come along");
drop(conn);
crate::store::open().expect("the database opens once nothing older is left");
}
}
#[test]
fn test20134_an_older_service_record_is_migrated_and_only_then_read() {
let temp = tempfile::tempdir().unwrap();
let root = crate::platform::canonicalize(temp.path()).unwrap();
let (machine, served, elsewhere) = (root.join("machine"), root.join("served"), root.join("elsewhere"));
for dir in [&machine, &served, &elsewhere] {
std::fs::create_dir_all(dir).unwrap();
}
let _env = Environment::set(&[("PRJ", ""), (crate::daemon::MACHINE_DIR_ENV, &machine.to_string_lossy())]);
let old = serde_json::json!({
"executable": "/usr/local/bin/wsb-daemon",
"arguments": ["--workspace", served.to_string_lossy(), "--port", "3000", "--peer-port", "7420"],
"workspace_root": served.to_string_lossy(),
"log": machine.join("daemon.log").to_string_lossy(),
"path": "/usr/bin",
"machine_dir": machine.to_string_lossy(),
"prj": null,
"autostart": true
});
let path = crate::daemon::service::record_path(&machine);
std::fs::write(&path, serde_json::to_string_pretty(&old).unwrap()).unwrap();
let before = crate::daemon::service::record(&machine).expect_err("an older record");
assert!(before.contains("run `wsb migrate`"), "{before}");
let report = crate::cli::annotations::migrate(&elsewhere).expect("a machine with no workshop migrates");
assert!(report.refused.is_empty(), "{:?}", report.refused);
assert!(report.done.iter().any(|line| line.contains("wsb daemon install")), "{:?}", report.done);
let record = crate::daemon::service::record(&machine).expect("read").expect("a record");
assert_eq!(record.worktree_root, served);
assert_eq!(&record.arguments[..2], ["--worktree".to_string(), served.to_string_lossy().into_owned()]);
assert!(record.autostart, "whether it starts at login was lost");
let again = crate::cli::annotations::migrate(&elsewhere).expect("again");
assert!(again.done.iter().all(|line| !line.contains("service")), "{:?}", again.done);
let mut both = old.clone();
both["worktree_root"] = both["workspace_root"].clone();
std::fs::write(&path, serde_json::to_string_pretty(&both).unwrap()).unwrap();
let refused = crate::cli::annotations::migrate(&elsewhere).expect("the report says what was refused");
assert!(refused.refused.iter().any(|line| line.contains("names the daemon's worktree twice")), "{:?}", refused.refused);
}
#[test]
fn test20138_the_oldest_layout_is_moved_and_named_by_its_project() {
let prj = tempfile::tempdir().unwrap();
let machine = tempfile::tempdir().unwrap();
let root = crate::platform::canonicalize(prj.path()).unwrap();
let _env = Environment::set(&[
("PRJ", &root.to_string_lossy()),
(crate::daemon::MACHINE_DIR_ENV, &machine.path().to_string_lossy()),
]);
let main = root.join("box-mux/main-worktree");
std::fs::create_dir_all(&main).unwrap();
git(&main, &["init", "--quiet"]);
std::fs::write(main.join("version.txt"), "0.9.1\n").unwrap();
let dot = main.join(".wsb");
std::fs::create_dir_all(dot.join("logs")).unwrap();
std::fs::write(dot.join("logs/wsb.log"), "line\n").unwrap();
std::fs::write(dot.join("state.json"), r#"{"version":1}"#).unwrap();
let db = rusqlite::Connection::open(dot.join("project.db")).unwrap();
db.execute_batch(
"CREATE TABLE projects (id TEXT, major_version INTEGER, version_file TEXT,
auto_detect_project_files INTEGER, project_files TEXT);
INSERT INTO projects VALUES ('P001', 3, 'version.txt', 0, '[]');
CREATE TABLE workbench_state (workspace_root TEXT PRIMARY KEY, state_json TEXT, updated_at TEXT);
INSERT INTO workbench_state VALUES ('/anywhere', '{\"groups\":[2]}', '');",
)
.unwrap();
drop(db);
let workshop = wsb_prj::Workshop::at(&root).unwrap();
let seen = leftovers_in(&main).unwrap();
for old in ["project.db", "logs"] {
assert!(seen.iter().any(|line| line.contains(&format!(".wsb/{old} "))), "{old} was not seen: {seen:?}");
}
let report = migrate_workshop(&workshop).unwrap();
assert!(report.refused.is_empty(), "{:?}", report.refused);
let settings = RepoSettings::require(&main).unwrap();
assert_eq!(settings.name, "box-mux");
assert_eq!(settings.alias, "box_mux");
assert_eq!(settings.version.major, 3, "the old database's major was lost");
assert!(!settings.version.detect_manifests, "the old database's manifest setting was lost");
let state = crate::local_state::LocalState::of(&main).unwrap();
assert_eq!(std::fs::read_to_string(state.logs().join("wsb.log")).unwrap(), "line\n", "the logs were not moved");
let store = rusqlite::Connection::open(crate::store::path().unwrap()).unwrap();
let layout: String = store
.query_row("SELECT state_json FROM workbench_state WHERE worktree = 'box-mux/main'", [], |row| row.get(0))
.unwrap();
assert_eq!(layout, "{\"expanded_paths\":[]}");
let left: Vec<String> = std::fs::read_dir(&dot)
.unwrap()
.map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
.collect();
assert_eq!(left, vec!["repo.json"], "the checkout's .wsb/ holds more than committed settings");
assert!(leftovers_in(&main).unwrap().is_empty());
}