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
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));
}

/// A workshop holding one workspace, `alpha`, laid out exactly as an older
/// wsb left it, and a machine folder of its own.
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();
    // The worktree database an older wsb kept: its version row, a layout, a
    // note nothing showed.
    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);
    // The exclude an older wsb wrote, beside a line of somebody's own.
    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();

    // The old GitHub mirror, apart in its own file, and the old peers.
    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(
            // As the previous wsb left it: a peer's name for a worktree was a
            // "workspace id", and a relationship from before `mode` has none.
            "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);

    // The workspace's state under its old name: boards, and sdx's snapshots.
    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,
    }
}

/// TEST20113: a workshop an older wsb set up is moved into this layout whole
/// — settings committed in the repository, local state under `$PRJ/.state`,
/// nothing of the old layout left — and moving it again changes nothing.
///
/// Every machine running wsb has checkouts in the old layout, and none of it
/// may be lost on the way: the version a repository is at, the boards its
/// project works from, its layout, its logs, the extensions installed. What
/// this wsb no longer keeps is said, not dropped silently.
#[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);
    // One database is left, the workshop's: none in a checkout, a workspace,
    // or the machine's folder beside it.
    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:?}");
}

/// TEST20114: what cannot be moved without guessing is refused, naming it,
/// and left as it was.
///
/// Two settings that disagree are a question about the repository; .tera
/// templates this wsb no longer renders would be lost by removing their
/// folder.
#[test]
fn test20114_what_cannot_be_moved_without_guessing_is_refused() {
    let layout = old_layout();
    let main = &layout.main;
    // A repo.json already there, saying something else.
    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");

    // With the disagreement settled, the templates are what is refused.
    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());
}

/// TEST20115: a pin an older wsb kept becomes a committed link from the
/// message to the project it was pinned in, and its note a note linked to the
/// message; the holding table goes.
///
/// The sessions database's migration holds the pins rather than dropping
/// them, because the daemon applies it the moment it opens the database —
/// before anybody has run `wsb migrate`.
#[test]
fn test20115_pins_become_committed_links() {
    let layout = old_layout();
    // The sessions database as an older wsb left it, with one pin and its
    // note, opened once by this wsb — which applies the migration that holds
    // the pin. The suite's machine folder is where the workshop's database
    // is, so it is already in place.
    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();
    // Recorded as applied up to V1, as an older wsb left it; opening it
    // now applies V2 and nothing else.
    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);
    // The machine's folder is where the suite keeps the workshop's database.
    assert_eq!(crate::store::path().unwrap(), layout.machine.path().join("wsb.db"));
    // Opened as the migration opens it: anything else is refused while the
    // older databases are there.
    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");
}

/// An older wsb's sessions database in this machine's host folder, holding
/// one session and a star.
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();
}

/// TEST20120: the machine's sessions database becomes the workshop's one
/// database by being moved into `$PRJ/.state`, stars and all; when this wsb
/// made that database first, the two are refused rather than merged, and
/// both are left as they were; and nothing moves while a daemon runs.
#[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();
        // A home of its own: with no machine folder named, the migration
        // also adopts what `~/.wsb` holds, and the real one is the machine's.
        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);

        // Nothing but the migration opens the database while an older one is
        // there: a command run first would make the second database.
        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");

        // A daemon running — this process stands in for one — moves nothing.
        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" {
            // A newer wsb that ran before this check made the database.
            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");
    }
}

/// TEST20134: the service record an older wsb kept is brought over by `wsb
/// migrate` — on a machine with no workshop too — and read by this wsb only
/// then: before, reading it says to run `wsb migrate`; after, it names the
/// daemon's worktree as this wsb does and starts the daemon with the flag
/// this daemon takes. One that names the worktree both ways is refused.
#[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);
}

/// TEST20138: the oldest layout — the checkout's database still called
/// `project.db`, logs straight in `.wsb/logs`, and a state file naming no
/// project — is moved like the newer one, and the project's main repo is
/// named by its project, not by its `main-worktree` folder.
///
/// A name taken from the folder named every project of the workshop
/// `main-worktree`, in the settings every clone reads.
#[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());
}