wsb 0.258.6441

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
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
//! A project's worktrees, made and removed whole: the superproject and every
//! submodule in it.
//!
//! A project under `$PRJ` is a clone, `main-worktree`, and its sibling
//! `<name>-worktree`s. Each worktree here is a git worktree of main's own
//! repository — and so is each of its submodules: `<wt>/<path>` is a worktree
//! of `main-worktree/<path>`'s repository, on a branch of its own. Objects are
//! shared, a commit made in one worktree's submodule is visible in main's at
//! once, and nothing is cloned.
//!
//! What `git worktree add` and `git submodule update` do instead is clone
//! every submodule again, into the new worktree's git directory: about a
//! gigabyte per worktree here, and a commit in one worktree's submodule is
//! invisible to every other until it is pushed and fetched.
//!
//! # Every registration is somebody's
//!
//! Each worktree made here is locked, and removal undoes exactly the
//! registrations it made, one at a time, bottom-up. Nothing here runs a
//! global `git worktree prune`: it removes the registration of every worktree
//! whose folder is missing at that moment — a drive not mounted, a folder
//! being moved — and that is how another worktree's submodules once stopped
//! being repositories.
//!
//! # Temporary worktrees
//!
//! A worktree can also be a snapshot of another one ([`snapshot`]): every
//! repository detached at the commit the source has checked out, with the
//! source's working changes over them when asked, for one operation to work
//! from while the source is left to whoever is working in it. It is taken
//! again in place — the same paths, so what a build keyed by them made is
//! reused — and removed like any other once nothing in it is unsaved. It
//! records what it is a snapshot of in its superproject's git directory, and
//! [`temporary`] reads that: a folder that is no repository, such as a plain
//! copy of a tree, is not one.

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

use serde::{Deserialize, Serialize};

/// The prefix a worktree's branch gets when none is named, unless
/// `PRJ_BRANCH_PREFIX` says otherwise — as `prj` names them.
pub const DEFAULT_BRANCH_PREFIX: &str = "card/";

/// One repository of a worktree, as made or found.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Checkout {
    /// Relative to the worktree; empty for the superproject.
    pub path: String,
    /// The branch it is on; none when detached.
    pub branch: Option<String>,
    pub commit: String,
}

/// A project's worktree, by `prj`'s names.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Target {
    pub project: wsb_prj::Project,
    pub name: String,
}

impl Target {
    /// `<project>/<name>`, as `prj` writes it. `main` is refused: the main
    /// worktree is the clone every other is a worktree of, and is made by
    /// cloning.
    pub fn parse(prj: &wsb_prj::Prj, raw: &str) -> Result<Self, String> {
        let (project, name) = raw
            .split_once('/')
            .ok_or_else(|| format!("'{raw}' is not <project>/<worktree>"))?;
        if !wsb_prj::is_valid_name(name) {
            return Err(format!(
                "'{name}' is not a worktree name: letters, digits, '.', '_' and '-', \
                 starting with a letter or digit"
            ));
        }
        if name == wsb_prj::MAIN {
            return Err(format!(
                "{project}'s main worktree is its clone, not a worktree of one"
            ));
        }
        Ok(Self {
            project: prj.project(project).map_err(|e| e.to_string())?,
            name: name.to_string(),
        })
    }

    pub fn path(&self) -> PathBuf {
        self.project.dir().join(wsb_prj::worktree_folder(&self.name))
    }

    pub fn main(&self) -> PathBuf {
        self.project.main_worktree()
    }
}

impl std::fmt::Display for Target {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}/{}", self.project.name(), self.name)
    }
}

/// The branch a new worktree goes on, when none is named.
pub fn default_branch(name: &str) -> String {
    let prefix = std::env::var("PRJ_BRANCH_PREFIX")
        .ok()
        .filter(|prefix| !prefix.is_empty())
        .unwrap_or_else(|| DEFAULT_BRANCH_PREFIX.to_string());
    format!("{prefix}{name}")
}

// ── git ──────────────────────────────────────────────────────────────────

fn git(dir: &Path, args: &[&str]) -> Result<String, String> {
    let output = crate::git_backend::git_command()
        .arg("-C")
        .arg(dir)
        .args(args)
        .output()
        .map_err(|e| format!("cannot run git in {}: {e}", dir.display()))?;
    if output.status.success() {
        Ok(String::from_utf8_lossy(&output.stdout).trim_end().to_string())
    } else {
        Err(format!(
            "git {} in {} failed: {}",
            args.join(" "),
            dir.display(),
            String::from_utf8_lossy(&output.stderr).trim()
        ))
    }
}

/// Whether `git` succeeds, for a question git answers with its exit status.
fn git_ok(dir: &Path, args: &[&str]) -> Result<bool, String> {
    let output = crate::git_backend::git_command()
        .arg("-C")
        .arg(dir)
        .args(args)
        .output()
        .map_err(|e| format!("cannot run git in {}: {e}", dir.display()))?;
    Ok(output.status.success())
}

fn path_arg(path: &Path) -> Result<&str, String> {
    path.to_str()
        .ok_or_else(|| format!("{} is not a path git can be given", path.display()))
}

/// The repository a checkout's objects live in.
fn common_dir(dir: &Path) -> Result<PathBuf, String> {
    let raw = git(dir, &["rev-parse", "--path-format=absolute", "--git-common-dir"])?;
    crate::platform::canonicalize(&raw).map_err(|e| format!("cannot resolve {raw}: {e}"))
}

/// The checkout's own git directory: for a worktree, its private one.
fn git_dir(dir: &Path) -> Result<PathBuf, String> {
    let raw = git(dir, &["rev-parse", "--path-format=absolute", "--git-dir"])?;
    crate::platform::canonicalize(&raw).map_err(|e| format!("cannot resolve {raw}: {e}"))
}

fn has_local_branch(repo: &Path, branch: &str) -> Result<bool, String> {
    git_ok(repo, &["rev-parse", "--verify", "--quiet", &format!("refs/heads/{branch}")])
}

fn has_remote_branch(repo: &Path, branch: &str) -> Result<bool, String> {
    git_ok(repo, &["rev-parse", "--verify", "--quiet", &format!("refs/remotes/origin/{branch}")])
}

fn has_commit(repo: &Path, commit: &str) -> Result<bool, String> {
    git_ok(repo, &["cat-file", "-e", &format!("{commit}^{{commit}}")])
}

fn branch_of(dir: &Path) -> Result<Option<String>, String> {
    let branch = git(dir, &["branch", "--show-current"])?;
    Ok((!branch.is_empty()).then_some(branch))
}

fn head_of(dir: &Path) -> Result<String, String> {
    git(dir, &["rev-parse", "HEAD"])
}

/// Every submodule a checkout records: its path and the commit its gitlink
/// pins, in `.gitmodules` order.
fn gitlinks(checkout: &Path) -> Result<Vec<(String, String)>, String> {
    if !checkout.join(".gitmodules").is_file() {
        return Ok(Vec::new());
    }
    let listed = git(
        checkout,
        &["config", "--file", ".gitmodules", "--get-regexp", r"^submodule\..*\.path$"],
    )
    .or_else(|error| {
        // `--get-regexp` exits 1 when nothing matches: a `.gitmodules` that
        // names no paths declares no submodules.
        if git_ok(checkout, &["config", "--file", ".gitmodules", "--list"]).unwrap_or(false) {
            Ok(String::new())
        } else {
            Err(error)
        }
    })?;
    let mut links = Vec::new();
    for line in listed.lines() {
        let (_, path) = line
            .split_once(' ')
            .ok_or_else(|| format!("{}/.gitmodules: cannot read {line:?}", checkout.display()))?;
        let entry = git(checkout, &["ls-tree", "HEAD", "--", path])?;
        // `160000 commit <sha>\t<path>`
        let mut fields = entry.split_whitespace();
        match (fields.next(), fields.next(), fields.next()) {
            (Some("160000"), Some("commit"), Some(sha)) => links.push((path.to_string(), sha.to_string())),
            _ => {
                return Err(format!(
                    "{}/.gitmodules declares {path}, and the commit there records no submodule at it",
                    checkout.display()
                ))
            }
        }
    }
    Ok(links)
}

/// The worktree registrations of the repository at `repo`, by path.
fn registrations(repo: &Path) -> Result<Vec<Registration>, String> {
    let listed = git(repo, &["worktree", "list", "--porcelain"])?;
    let mut found = Vec::new();
    for block in listed.split("\n\n") {
        let mut registration = Registration::default();
        for line in block.lines() {
            if let Some(path) = line.strip_prefix("worktree ") {
                registration.path = PathBuf::from(path);
            } else if let Some(branch) = line.strip_prefix("branch refs/heads/") {
                registration.branch = Some(branch.to_string());
            } else if line == "locked" || line.starts_with("locked ") {
                registration.locked = true;
            } else if line == "prunable" || line.starts_with("prunable ") {
                registration.prunable = true;
            }
        }
        if !registration.path.as_os_str().is_empty() {
            found.push(registration);
        }
    }
    Ok(found)
}

#[derive(Debug, Default, Clone, PartialEq, Eq)]
struct Registration {
    path: PathBuf,
    branch: Option<String>,
    locked: bool,
    prunable: bool,
}

fn same_path(one: &Path, other: &Path) -> bool {
    match (crate::platform::canonicalize(one), crate::platform::canonicalize(other)) {
        (Ok(one), Ok(other)) => one == other,
        _ => one == other,
    }
}

/// Drop the one registration `repo` holds for `path`, when its folder is not
/// a checkout of it any more — what a move leaves behind. Exactly that one:
/// every other registration, missing folder or not, is somebody else's.
fn forget_stale_registration(repo: &Path, path: &Path) -> Result<(), String> {
    let admin = common_dir(repo)?.join("worktrees");
    let Ok(entries) = std::fs::read_dir(&admin) else {
        return Ok(());
    };
    let wanted = path.join(".git");
    for entry in entries {
        let entry = entry.map_err(|e| format!("cannot read {}: {e}", admin.display()))?;
        let Ok(recorded) = std::fs::read_to_string(entry.path().join("gitdir")) else {
            continue;
        };
        if !same_path(Path::new(recorded.trim()), &wanted) && Path::new(recorded.trim()) != wanted {
            continue;
        }
        if path.join(".git").is_file() && git_ok(path, &["rev-parse", "--git-dir"])? {
            return Err(format!(
                "{} is still a checkout of {}; it is not forgotten",
                path.display(),
                repo.display()
            ));
        }
        std::fs::remove_dir_all(entry.path())
            .map_err(|e| format!("cannot remove {}: {e}", entry.path().display()))?;
    }
    Ok(())
}

/// Which worktree of `repo` has `branch` checked out, if one does.
fn holder_of(repo: &Path, branch: &str) -> Result<Option<PathBuf>, String> {
    Ok(registrations(repo)?
        .into_iter()
        .find(|registration| registration.branch.as_deref() == Some(branch))
        .map(|registration| registration.path))
}

// ── add ──────────────────────────────────────────────────────────────────

/// What `add` did, to undo it if a later step fails.
struct Made {
    repo: PathBuf,
    path: PathBuf,
    /// The branch `add` created for it, which undoing deletes.
    created_branch: Option<String>,
}

/// Make `<project>/<name>`: the superproject on `branch`, and every
/// submodule, recursively, a worktree of main's repository for it on the
/// same branch name — the branch where it exists, else made at the commit
/// the superproject pins.
///
/// The superproject's branch is chosen as `prj` chooses it: a local branch of
/// that name, else `origin/<branch>` tracked, else a new branch from `from`
/// (default `origin/HEAD`). Every worktree made is locked.
///
/// All or nothing: a step that fails undoes every one before it.
pub fn add(target: &Target, branch: Option<&str>, from: Option<&str>) -> Result<Vec<Checkout>, String> {
    let path = target.path();
    if path.exists() {
        return Err(format!("cannot create worktree {target}: {} already exists", path.display()));
    }
    let main = target.main();
    let branch = branch.map(str::to_string).unwrap_or_else(|| default_branch(&target.name));
    let reason = format!("wsb worktree {target}");
    let mut made = Vec::new();
    let outcome = (|| -> Result<Vec<Checkout>, String> {
        // The superproject.
        let destination = path_arg(&path)?;
        let created = if has_local_branch(&main, &branch)? {
            git(&main, &["worktree", "add", "--quiet", destination, &branch])?;
            None
        } else if has_remote_branch(&main, &branch)? {
            git(
                &main,
                &["worktree", "add", "--quiet", "--track", "-b", &branch, destination, &format!("origin/{branch}")],
            )?;
            Some(branch.clone())
        } else {
            let base = match from {
                Some(from) => from.to_string(),
                None => git(&main, &["rev-parse", "--abbrev-ref", "origin/HEAD"]).map_err(|_| {
                    format!(
                        "{} has no origin/HEAD to start {branch} from; name one with --from",
                        main.display()
                    )
                })?,
            };
            git(&main, &["worktree", "add", "--quiet", "--no-track", "-b", &branch, destination, &base])?;
            Some(branch.clone())
        };
        made.push(Made {
            repo: main.clone(),
            path: path.clone(),
            created_branch: created,
        });
        git(&main, &["worktree", "lock", "--reason", &reason, destination])?;

        let mut checkouts = vec![Checkout {
            path: String::new(),
            branch: branch_of(&path)?,
            commit: head_of(&path)?,
        }];
        add_submodules(&main, &path, "", &branch, &reason, &mut made, &mut checkouts)?;
        Ok(checkouts)
    })();
    match outcome {
        Ok(checkouts) => Ok(checkouts),
        Err(error) => {
            let undone = undo(made);
            Err(match undone {
                Ok(()) => format!("{error}\n  nothing of {target} was left behind"),
                Err(stuck) => format!("{error}\n  and undoing what was made failed: {stuck}"),
            })
        }
    }
}

/// Place every submodule of the checkout at `here`, a worktree of the same
/// submodule's repository in `there` (main's checkout of the same tree).
fn add_submodules(
    there: &Path,
    here: &Path,
    prefix: &str,
    branch: &str,
    reason: &str,
    made: &mut Vec<Made>,
    checkouts: &mut Vec<Checkout>,
) -> Result<(), String> {
    for (path, commit) in gitlinks(here)? {
        let repo = there.join(&path);
        let destination = here.join(&path);
        let shown = format!("{prefix}{path}");
        if !repo.join(".git").exists() {
            return Err(format!(
                "{} is not checked out in main, so its repository cannot be shared; run \
                 `git submodule update --init --recursive` in {} first",
                shown,
                there.display()
            ));
        }
        let arg = path_arg(&destination)?;
        let created = if has_local_branch(&repo, branch)? {
            git(&repo, &["worktree", "add", "--quiet", arg, branch])?;
            None
        } else {
            if !has_commit(&repo, &commit)? {
                return Err(format!(
                    "{shown}'s repository in main has no commit {commit}, which the worktree pins; \
                     fetch it in {} first",
                    repo.display()
                ));
            }
            git(&repo, &["worktree", "add", "--quiet", "-b", branch, arg, &commit])?;
            Some(branch.to_string())
        };
        made.push(Made {
            repo: repo.clone(),
            path: destination.clone(),
            created_branch: created,
        });
        git(&repo, &["worktree", "lock", "--reason", reason, arg])?;
        checkouts.push(Checkout {
            path: shown.clone(),
            branch: branch_of(&destination)?,
            commit: head_of(&destination)?,
        });
        add_submodules(&repo, &destination, &format!("{shown}/"), branch, reason, made, checkouts)?;
    }
    Ok(())
}

/// Undo what `add` made, newest first.
fn undo(made: Vec<Made>) -> Result<(), String> {
    let mut failures = Vec::new();
    for made in made.into_iter().rev() {
        let arg = match path_arg(&made.path) {
            Ok(arg) => arg.to_string(),
            Err(error) => {
                failures.push(error);
                continue;
            }
        };
        let _ = git(&made.repo, &["worktree", "unlock", &arg]);
        if let Err(error) = git(&made.repo, &["worktree", "remove", "--force", "--force", &arg]) {
            failures.push(error);
            continue;
        }
        if let Some(branch) = made.created_branch {
            if let Err(error) = git(&made.repo, &["branch", "-D", &branch]) {
                failures.push(error);
            }
        }
    }
    if failures.is_empty() {
        Ok(())
    } else {
        Err(failures.join("; "))
    }
}

// ── the repositories of a worktree ───────────────────────────────────────

/// One repository of an existing worktree, and main's checkout of it.
#[derive(Debug, Clone)]
struct Member {
    /// Relative to the worktree; empty for the superproject.
    path: String,
    here: PathBuf,
    there: PathBuf,
    depth: usize,
    /// Its `.git` names a repository that is not there: the registration was
    /// pruned, and the checkout is files git no longer knows.
    broken: bool,
}

/// Whether the checkout at `dir` has a `.git` that names no repository.
fn is_broken(dir: &Path) -> Result<bool, String> {
    Ok(dir.join(".git").exists() && !git_ok(dir, &["rev-parse", "--git-dir"])?)
}

/// Every repository checked out in the worktree at `root`, parent before
/// child, beside main's checkout of each.
fn members(root: &Path, main: &Path) -> Result<Vec<Member>, String> {
    let mut found = vec![Member {
        path: String::new(),
        here: root.to_path_buf(),
        there: main.to_path_buf(),
        depth: 0,
        broken: false,
    }];
    let mut index = 0;
    while index < found.len() {
        let parent = found[index].clone();
        index += 1;
        // Nothing below a broken checkout can be read: its submodules are
        // recorded in a repository that is gone.
        if parent.broken {
            continue;
        }
        for (path, _) in gitlinks(&parent.here)? {
            let here = parent.here.join(&path);
            if !here.join(".git").exists() {
                continue;
            }
            found.push(Member {
                path: if parent.path.is_empty() { path.clone() } else { format!("{}/{path}", parent.path) },
                broken: is_broken(&here)?,
                here,
                there: parent.there.join(&path),
                depth: parent.depth + 1,
            });
        }
    }
    Ok(found)
}

/// Why a repository's work would be lost by removing it, if it would.
///
/// A worktree's commits must be on a remote: its branch goes with it, or is
/// deleted. A temporary worktree's commits up to the one it was taken at
/// (`taken_at`) are its source's, kept there whether or not any branch has
/// them; what is above that commit and on no branch was made in the
/// temporary worktree, and would be lost.
fn unsaved(dir: &Path, taken_at: Option<&str>) -> Result<Option<String>, String> {
    let status = git(dir, &["status", "--porcelain", "--untracked-files=all", "--ignore-submodules=all"])?;
    if !status.is_empty() {
        let count = status.lines().count();
        return Ok(Some(format!("{count} uncommitted change(s), untracked files included")));
    }
    if let Some(taken_at) = taken_at {
        let made = git(dir, &["rev-list", "--count", "HEAD", "--not", taken_at, "--branches", "--tags", "--remotes"])?;
        if made != "0" {
            return Ok(Some(format!("{made} commit(s) made here, on no branch")));
        }
        return Ok(None);
    }
    let unpushed = git(dir, &["rev-list", "--count", "HEAD", "--not", "--remotes"])?;
    if unpushed != "0" {
        return Ok(Some(format!("{unpushed} commit(s) on no remote branch")));
    }
    Ok(None)
}

// ── rm ───────────────────────────────────────────────────────────────────

/// Remove `<project>/<name>`, every repository in it, bottom-up.
///
/// Refused, before anything is touched, unless every repository is clean —
/// untracked files included — and every commit it is on is on a remote. Each
/// registration is unlocked and removed by itself; nothing else is pruned.
/// With `delete_branch`, the branch each repository was on is deleted after,
/// with `git branch -d`, which keeps one that is not merged.
pub fn remove(target: &Target, delete_branch: bool) -> Result<Vec<Checkout>, String> {
    let path = target.path();
    if !path.is_dir() {
        return Err(format!("{target} is not there: {} does not exist", path.display()));
    }
    let main = target.main();
    if common_dir(&path)? != common_dir(&main)? {
        return Err(format!(
            "{} is not a worktree of {}; it is not removed",
            path.display(),
            main.display()
        ));
    }
    let members = members(&path, &main)?;
    let temporary = temporary(&path)?;
    let mut refused = Vec::new();
    for member in &members {
        if member.broken {
            refused.push(format!(
                "  {}: git no longer knows this checkout — `wsb worktree migrate {target}` repairs it",
                member.path
            ));
            continue;
        }
        let shown = if member.path.is_empty() { ".".to_string() } else { member.path.clone() };
        let taken_at = match &temporary {
            Some(temporary) => Some(temporary.commits.get(&shown).map(String::as_str).ok_or_else(|| {
                format!("{target} is a temporary worktree whose record names no commit for {shown}; it is not removed")
            })?),
            None => None,
        };
        if let Some(why) = unsaved(&member.here, taken_at)? {
            refused.push(format!("  {shown}: {why}"));
        }
        if !member.path.is_empty() && common_dir(&member.here)? != common_dir(&member.there)? {
            refused.push(format!(
                "  {}: a separate clone, not a worktree of main's repository — \
                 `wsb worktree migrate {target}` first",
                member.path
            ));
        }
    }
    if !refused.is_empty() {
        return Err(format!(
            "{target} is not removed; its work would be lost:\n{}",
            refused.join("\n")
        ));
    }

    let mut removed = Vec::new();
    let mut ordered = members;
    ordered.sort_by(|a, b| b.depth.cmp(&a.depth));
    for member in ordered {
        let branch = branch_of(&member.here)?;
        let commit = head_of(&member.here)?;
        let arg = path_arg(&member.here)?.to_string();
        let repo = if member.path.is_empty() { main.clone() } else { member.there.clone() };
        // Its submodules' worktrees are gone by now, and git counts a
        // submodule with no checkout as a change. Every repository was found
        // clean above, its submodules included, so this one is forced past
        // that alone.
        let parent = !gitlinks(&member.here)?.is_empty();
        if registrations(&repo)?
            .iter()
            .any(|registration| same_path(&registration.path, &member.here) && registration.locked)
        {
            git(&repo, &["worktree", "unlock", &arg])?;
        }
        if parent {
            git(&repo, &["worktree", "remove", "--force", &arg])?;
        } else {
            git(&repo, &["worktree", "remove", &arg])?;
        }
        if delete_branch {
            if let Some(branch) = &branch {
                git(&repo, &["branch", "-d", branch]).map_err(|error| {
                    format!("{} is removed, and its branch {branch} is kept: {error}", member.here.display())
                })?;
            }
        }
        removed.push(Checkout {
            path: member.path,
            branch,
            commit,
        });
    }
    Ok(removed)
}

// ── snapshot ─────────────────────────────────────────────────────────────

/// What a temporary worktree records about itself, in its superproject's git
/// directory.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Temporary {
    /// The worktree it is a snapshot of: `main`, or another worktree's name.
    pub of: String,
    /// Whether it holds its source's working changes over the commits.
    pub working: bool,
    /// Each repository's commit when it was last taken, by its path in the
    /// worktree: `.` for the superproject.
    pub commits: BTreeMap<String, String>,
}

/// The file in a temporary worktree's superproject git directory that says it
/// is one. Kept there so it goes when the worktree's registration does.
const TEMPORARY: &str = "wsb-temporary.json";

/// What the worktree whose folder is `root` is a snapshot of, if it is a
/// temporary worktree. A folder that is no git repository — a plain copy of a
/// tree — is not one, and neither is any other worktree.
pub fn temporary(root: &Path) -> Result<Option<Temporary>, String> {
    if !root.is_dir() || !git_ok(root, &["rev-parse", "--is-inside-work-tree"])? {
        return Ok(None);
    }
    let file = git_dir(root)?.join(TEMPORARY);
    match std::fs::read_to_string(&file) {
        Ok(text) => serde_json::from_str(&text)
            .map(Some)
            .map_err(|e| format!("{} is not a temporary worktree's record: {e}", file.display())),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
        Err(e) => Err(format!("cannot read {}: {e}", file.display())),
    }
}

/// One repository a snapshot is taken of.
struct Source {
    /// Relative to the worktree; empty for the superproject.
    path: String,
    /// The source's checkout of it.
    here: PathBuf,
    /// The repository a snapshot's checkout of it is a worktree of: main's.
    repo: PathBuf,
    /// The commit the source has checked out.
    commit: String,
}

/// Every repository the worktree at `source` has checked out, parent before
/// child, each refused unless a snapshot of it can be a worktree of main's
/// repository for it.
fn sources(source: &Path, main: &Path, of: &str, project: &str) -> Result<Vec<Source>, String> {
    let mut found = vec![Source {
        path: String::new(),
        here: source.to_path_buf(),
        repo: main.to_path_buf(),
        commit: head_of(source)?,
    }];
    let mut refused = Vec::new();
    let mut index = 0;
    while index < found.len() {
        let parent_path = found[index].path.clone();
        let parent_here = found[index].here.clone();
        let parent_repo = found[index].repo.clone();
        index += 1;
        for (path, _) in gitlinks(&parent_here)? {
            let shown = if parent_path.is_empty() { path.clone() } else { format!("{parent_path}/{path}") };
            let here = parent_here.join(&path);
            let repo = parent_repo.join(&path);
            if !here.join(".git").exists() {
                refused.push(format!("  {shown}: not checked out in {of}, so a snapshot of it would not be {of}"));
                continue;
            }
            if is_broken(&here)? {
                refused.push(format!("  {shown}: git no longer knows this checkout — `wsb worktree migrate {project}/{of}` repairs it"));
                continue;
            }
            if !repo.join(".git").exists() || common_dir(&here)? != common_dir(&repo)? {
                refused.push(format!(
                    "  {shown}: a separate clone, not a worktree of main's repository — \
                     `wsb worktree migrate {project}/{of}` first"
                ));
                continue;
            }
            found.push(Source {
                path: shown,
                commit: head_of(&here)?,
                here,
                repo,
            });
        }
    }
    if !refused.is_empty() {
        return Err(format!("no snapshot of {project}/{of} is taken:\n{}", refused.join("\n")));
    }
    Ok(found)
}

/// Take `<project>/<name>` as a snapshot of the worktree `of` (`main`, or a
/// worktree's name): every repository `of` has checked out — the
/// superproject, and each submodule as checked out there, not as the
/// superproject pins it — a worktree of main's repository, detached at that
/// checkout's commit, and locked. With `working`, what `of`'s working trees
/// hold over those commits — modified, added, untracked and not ignored,
/// deleted — is put over them. Each checkout's `.wsb/state.json` and
/// `.wsb/templates` are copied, so wsb renders and commits there as in `of`.
///
/// Taken again, it is refreshed in place: each checkout moved to its new
/// commit and cleaned of untracked files the commit does not have, keeping
/// ignored ones — build outputs — for the next run. A repository `of` no
/// longer has is removed from it, and one it now has is added.
///
/// Refused when a worktree of that name is not a temporary one, or is a
/// snapshot of another worktree; when a repository of `of` is not checked
/// out, is broken, or is a clone of its own rather than a worktree of
/// main's. A first taking that fails leaves nothing behind.
pub fn snapshot(target: &Target, of: &str, working: bool) -> Result<Vec<Checkout>, String> {
    let path = target.path();
    let main = target.main();
    let project = target.project.name().to_string();
    if of != wsb_prj::MAIN && !wsb_prj::is_valid_name(of) {
        return Err(format!("'{of}' is not a worktree name"));
    }
    if of == target.name {
        return Err(format!("{target} cannot be a snapshot of itself"));
    }
    let source = target.project.dir().join(wsb_prj::worktree_folder(of));
    if !source.is_dir() {
        return Err(format!("{project}/{of} is not there: {} does not exist", source.display()));
    }
    let earlier = if path.exists() {
        match temporary(&path)? {
            Some(earlier) if earlier.of == of => Some(earlier),
            Some(earlier) => {
                return Err(format!(
                    "{target} is a snapshot of {project}/{}, not of {project}/{of}; it is left as it is",
                    earlier.of
                ))
            }
            None => {
                return Err(format!(
                    "{target} is a worktree of its own at {}, not a temporary one; it is left as it is",
                    path.display()
                ))
            }
        }
    } else {
        None
    };
    let sources = sources(&source, &main, of, &project)?;
    let reason = format!("wsb worktree snapshot {target}");
    let mut made = Vec::new();
    let outcome = (|| -> Result<Vec<Checkout>, String> {
        // What an earlier taking placed that `of` no longer has goes first,
        // deepest first, one registration at a time.
        if let Some(earlier) = &earlier {
            let mut gone: Vec<&String> = earlier
                .commits
                .keys()
                .filter(|shown| shown.as_str() != "." && !sources.iter().any(|s| &s.path == *shown))
                .collect();
            gone.sort_by_key(|shown| std::cmp::Reverse(shown.matches('/').count()));
            for shown in gone {
                let checkout = path.join(shown);
                if !checkout.join(".git").exists() {
                    continue;
                }
                let repo = main.join(shown);
                let arg = path_arg(&checkout)?.to_string();
                let _ = git(&repo, &["worktree", "unlock", &arg]);
                git(&repo, &["worktree", "remove", "--force", "--force", &arg])?;
            }
        }
        let mut checkouts = Vec::new();
        let mut commits = BTreeMap::new();
        for source in &sources {
            let destination = if source.path.is_empty() { path.clone() } else { path.join(&source.path) };
            let arg = path_arg(&destination)?.to_string();
            if destination.join(".git").exists() {
                if common_dir(&destination)? != common_dir(&source.repo)? {
                    return Err(format!(
                        "{} is a checkout of another repository than {}; remove {target} and take it again",
                        destination.display(),
                        source.repo.display()
                    ));
                }
                git(&destination, &["checkout", "--quiet", "--force", "--detach", &source.commit])?;
                git(&destination, &["clean", "--quiet", "-fd"])?;
            } else {
                if destination.exists()
                    && std::fs::read_dir(&destination)
                        .map_err(|e| format!("cannot read {}: {e}", destination.display()))?
                        .next()
                        .is_some()
                {
                    return Err(format!(
                        "{} holds files and is no checkout of {}; remove {target} and take it again",
                        destination.display(),
                        source.repo.display()
                    ));
                }
                forget_stale_registration(&source.repo, &destination)?;
                git(&source.repo, &["worktree", "add", "--quiet", "--detach", &arg, &source.commit])?;
                made.push(Made {
                    repo: source.repo.clone(),
                    path: destination.clone(),
                    created_branch: None,
                });
                git(&source.repo, &["worktree", "lock", "--reason", &reason, &arg])?;
            }
            if working {
                overlay(&source.here, &destination)?;
            }
            copy_wsb_markers(&source.here, &destination)?;
            commits.insert(if source.path.is_empty() { ".".to_string() } else { source.path.clone() }, source.commit.clone());
            checkouts.push(Checkout {
                path: source.path.clone(),
                branch: None,
                commit: source.commit.clone(),
            });
        }
        let record = Temporary {
            of: of.to_string(),
            working,
            commits,
        };
        let file = git_dir(&path)?.join(TEMPORARY);
        let text = serde_json::to_string_pretty(&record).map_err(|e| e.to_string())?;
        std::fs::write(&file, text).map_err(|e| format!("cannot write {}: {e}", file.display()))?;
        Ok(checkouts)
    })();
    match outcome {
        Ok(checkouts) => Ok(checkouts),
        Err(error) if earlier.is_none() => Err(match undo(made) {
            Ok(()) => format!("{error}\n  nothing of {target} was left behind"),
            Err(stuck) => format!("{error}\n  and undoing what was made failed: {stuck}"),
        }),
        Err(error) => Err(format!("{error}\n  {target} is part-way refreshed; take it again")),
    }
}

/// Put what the working tree at `from` holds over its HEAD into `to`, a
/// checkout of the same commit: what is modified, added or untracked and not
/// ignored is copied, what is deleted is removed. A submodule's changes are
/// its own repository's, put over its own checkout.
fn overlay(from: &Path, to: &Path) -> Result<(), String> {
    let changed = git(from, &["diff", "--name-only", "-z", "HEAD", "--ignore-submodules=all"])?;
    let untracked = git(from, &["ls-files", "--others", "--exclude-standard", "-z"])?;
    for relative in changed.split('\0').chain(untracked.split('\0')).filter(|p| !p.is_empty()) {
        let source = from.join(relative);
        let destination = to.join(relative);
        let meta = match std::fs::symlink_metadata(&source) {
            Ok(meta) => meta,
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
                if std::fs::symlink_metadata(&destination).is_ok() {
                    std::fs::remove_file(&destination)
                        .map_err(|e| format!("cannot remove {}: {e}", destination.display()))?;
                }
                continue;
            }
            Err(e) => return Err(format!("cannot read {}: {e}", source.display())),
        };
        if meta.is_dir() {
            continue;
        }
        copy_file(&source, &destination, &meta)?;
    }
    Ok(())
}

/// The markers wsb keeps untracked in a checkout — `.wsb/state.json` and
/// `.wsb/templates` — from `from` into `to`, replacing what `to` had.
fn copy_wsb_markers(from: &Path, to: &Path) -> Result<(), String> {
    for name in ["state.json", "templates"] {
        let source = from.join(".wsb").join(name);
        let destination = to.join(".wsb").join(name);
        if std::fs::symlink_metadata(&destination).is_ok_and(|meta| meta.is_dir()) {
            std::fs::remove_dir_all(&destination)
                .map_err(|e| format!("cannot remove {}: {e}", destination.display()))?;
        }
        match std::fs::symlink_metadata(&source) {
            Ok(meta) if meta.is_dir() => copy_tree(&source, &destination)?,
            Ok(meta) => copy_file(&source, &destination, &meta)?,
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
                if std::fs::symlink_metadata(&destination).is_ok() {
                    std::fs::remove_file(&destination)
                        .map_err(|e| format!("cannot remove {}: {e}", destination.display()))?;
                }
            }
            Err(e) => return Err(format!("cannot read {}: {e}", source.display())),
        }
    }
    Ok(())
}

fn copy_tree(from: &Path, to: &Path) -> Result<(), String> {
    std::fs::create_dir_all(to).map_err(|e| format!("cannot create {}: {e}", to.display()))?;
    for entry in std::fs::read_dir(from).map_err(|e| format!("cannot read {}: {e}", from.display()))? {
        let entry = entry.map_err(|e| format!("cannot read {}: {e}", from.display()))?;
        let meta = std::fs::symlink_metadata(entry.path())
            .map_err(|e| format!("cannot read {}: {e}", entry.path().display()))?;
        let destination = to.join(entry.file_name());
        if meta.is_dir() {
            copy_tree(&entry.path(), &destination)?;
        } else {
            copy_file(&entry.path(), &destination, &meta)?;
        }
    }
    Ok(())
}

/// Copy one file or link, replacing what is at `to`; a link stays a link.
fn copy_file(from: &Path, to: &Path, meta: &std::fs::Metadata) -> Result<(), String> {
    if let Some(parent) = to.parent() {
        std::fs::create_dir_all(parent).map_err(|e| format!("cannot create {}: {e}", parent.display()))?;
    }
    if std::fs::symlink_metadata(to).is_ok() {
        std::fs::remove_file(to).map_err(|e| format!("cannot replace {}: {e}", to.display()))?;
    }
    if meta.file_type().is_symlink() {
        let pointed = std::fs::read_link(from).map_err(|e| format!("cannot read {}: {e}", from.display()))?;
        return crate::platform::symlink_file(&pointed, to)
            .map_err(|e| format!("cannot link {}: {e}", to.display()));
    }
    std::fs::copy(from, to).map_err(|e| format!("cannot copy {} to {}: {e}", from.display(), to.display()))?;
    Ok(())
}

// ── ls ───────────────────────────────────────────────────────────────────

/// One worktree of a project, and how its submodules are kept.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Listed {
    pub project: String,
    pub name: String,
    pub path: String,
    pub branch: Option<String>,
    /// Submodules that are worktrees of main's repositories.
    pub shared: Vec<String>,
    /// Submodules that are separate clones: `migrate` makes them shared.
    pub cloned: Vec<String>,
    /// Submodules recorded and not checked out.
    pub missing: Vec<String>,
    /// Checkouts whose `.git` names a repository that is not there:
    /// `migrate` repairs them.
    pub broken: Vec<String>,
    /// What it is a snapshot of, when it is a temporary worktree.
    pub temporary: Option<Temporary>,
}

/// Every worktree of `project`, main included.
pub fn list(project: &wsb_prj::Project) -> Result<Vec<Listed>, String> {
    let main = project.main_worktree();
    let mut listed = Vec::new();
    for worktree in project.worktrees().map_err(|e| e.to_string())? {
        let mut entry = Listed {
            project: project.name().to_string(),
            name: worktree.name.clone(),
            path: worktree.path.display().to_string(),
            branch: branch_of(&worktree.path)?,
            shared: Vec::new(),
            cloned: Vec::new(),
            missing: Vec::new(),
            broken: Vec::new(),
            temporary: temporary(&worktree.path)?,
        };
        walk(&worktree.path, &main, "", &mut entry)?;
        listed.push(entry);
    }
    Ok(listed)
}

fn walk(here: &Path, there: &Path, prefix: &str, entry: &mut Listed) -> Result<(), String> {
    for (path, _) in gitlinks(here)? {
        let shown = format!("{prefix}{path}");
        let checkout = here.join(&path);
        if !checkout.join(".git").exists() {
            entry.missing.push(shown);
            continue;
        }
        if is_broken(&checkout)? {
            entry.broken.push(shown);
            continue;
        }
        let theirs = there.join(&path);
        if same_path(&checkout, &theirs) || (theirs.join(".git").exists() && common_dir(&checkout)? == common_dir(&theirs)?) {
            entry.shared.push(shown.clone());
        } else {
            entry.cloned.push(shown.clone());
        }
        walk(&checkout, &theirs, &format!("{shown}/"), entry)?;
    }
    Ok(())
}

// ── migrate ──────────────────────────────────────────────────────────────

/// Make an older worktree's separate submodule clones worktrees of main's
/// repositories, on the commits and branches they are on.
///
/// 1. Every clone is checked clean, untracked files included, or nothing is
///    done. Its branches and its HEAD are fetched into main's repository for
///    it under `refs/salvage/<worktree>/`, so no commit only the clone had is
///    lost — whatever happens next. Each clone has a repository of its own in
///    main, so the worktree's name is all the ref needs; the submodule's path
///    is not in it, and could not be where it has a component a ref name may
///    not have (`.github`).
/// 2. The clones are removed, checkouts and git directories.
/// 3. Each submodule is put back as a worktree of main's repository: on its
///    branch where main's repository can give it one, detached at its commit
///    where that branch is checked out in another worktree. Submodules that
///    were never checked out are added as `add` adds them, on the worktree's
///    branch. Each is locked.
///
/// A worktree with nothing to migrate is left as it is.
pub fn migrate(target: &Target) -> Result<Vec<Checkout>, String> {
    let path = target.path();
    let main = target.main();
    if !path.is_dir() {
        return Err(format!("{target} is not there: {} does not exist", path.display()));
    }
    if common_dir(&path)? != common_dir(&main)? {
        return Err(format!(
            "{} is not a worktree of {}; only its submodules are migrated, and it would \
             have to be one",
            path.display(),
            main.display()
        ));
    }
    let branch = branch_of(&path)?.unwrap_or_else(|| default_branch(&target.name));

    // What is a clone, and what each one is on.
    struct Clone {
        member: Member,
        branch: Option<String>,
        commit: String,
        git_dir: PathBuf,
    }
    let mut clones = Vec::new();
    let mut refused = Vec::new();
    let mut orphans = Vec::new();
    for member in members(&path, &main)? {
        if member.path.is_empty() {
            continue;
        }
        if member.broken {
            orphans.push(member);
            continue;
        }
        if !member.there.join(".git").exists() {
            refused.push(format!("  {}: main has not checked it out", member.path));
            continue;
        }
        if common_dir(&member.here)? == common_dir(&member.there)? {
            continue;
        }
        let status = git(&member.here, &["status", "--porcelain", "--untracked-files=all", "--ignore-submodules=all"])?;
        if !status.is_empty() {
            refused.push(format!("  {}: {} uncommitted change(s)", member.path, status.lines().count()));
            continue;
        }
        clones.push(Clone {
            branch: branch_of(&member.here)?,
            commit: head_of(&member.here)?,
            git_dir: git_dir(&member.here)?,
            member,
        });
    }
    if !refused.is_empty() {
        return Err(format!("{target} is not migrated:\n{}", refused.join("\n")));
    }

    // 1. Salvage, deepest first — before anything is removed.
    let prefix = format!("refs/salvage/{}", target.name);
    for clone in clones.iter().rev() {
        let source = path_arg(&clone.member.here)?;
        git(
            &clone.member.there,
            &["fetch", "--quiet", "--no-tags", source, &format!("+refs/heads/*:{prefix}/heads/*"), &format!("+HEAD:{prefix}/HEAD")],
        )?;
        if !has_commit(&clone.member.there, &clone.commit)? {
            return Err(format!(
                "{} did not receive {} from the clone at {}; nothing was removed",
                clone.member.there.display(),
                clone.commit,
                clone.member.here.display()
            ));
        }
    }

    // 2. Remove the clones: only the outermost ones' checkouts — a nested
    // clone is inside its parent's — and every one's git directory.
    for clone in &clones {
        let nested = clones.iter().any(|outer| {
            outer.member.depth < clone.member.depth
                && clone.member.path.starts_with(&format!("{}/", outer.member.path))
        });
        if !nested {
            std::fs::remove_dir_all(&clone.member.here)
                .map_err(|e| format!("cannot remove the clone at {}: {e}", clone.member.here.display()))?;
            std::fs::create_dir_all(&clone.member.here)
                .map_err(|e| format!("cannot recreate {}: {e}", clone.member.here.display()))?;
        }
    }
    // Each clone's git directory is inside the superproject worktree's own,
    // under `modules/`; the folders left empty go with them.
    let own = git_dir(&path)?;
    for clone in &clones {
        if clone.git_dir.exists() {
            std::fs::remove_dir_all(&clone.git_dir)
                .map_err(|e| format!("cannot remove {}: {e}", clone.git_dir.display()))?;
        }
        let mut parent = clone.git_dir.parent();
        while let Some(folder) = parent {
            if folder == own || !folder.starts_with(&own) {
                break;
            }
            let empty = std::fs::read_dir(folder).map(|mut entries| entries.next().is_none()).unwrap_or(false);
            if !empty {
                break;
            }
            std::fs::remove_dir(folder).map_err(|e| format!("cannot remove {}: {e}", folder.display()))?;
            parent = folder.parent();
        }
    }

    // A broken checkout has nothing git can salvage: it is moved out of the
    // worktree whole, to the project's state folder, and compared with what
    // is put back below. Kept there only when it differs.
    let aside = target
        .project
        .state_dir()
        .join("orphaned")
        .join(&target.name)
        .join(
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .map_err(|e| e.to_string())?
                .as_secs()
                .to_string(),
        );
    let mut moved = Vec::new();
    for orphan in &orphans {
        let destination = aside.join(&orphan.path);
        if let Some(parent) = destination.parent() {
            std::fs::create_dir_all(parent).map_err(|e| format!("cannot create {}: {e}", parent.display()))?;
        }
        std::fs::rename(&orphan.here, &destination).map_err(|e| {
            format!("cannot move {} aside to {}: {e}", orphan.here.display(), destination.display())
        })?;
        std::fs::create_dir_all(&orphan.here)
            .map_err(|e| format!("cannot recreate {}: {e}", orphan.here.display()))?;
        moved.push((orphan.path.clone(), orphan.here.clone(), destination));
    }

    // 3. Put each back, parent before child, and add what was never there.
    let reason = format!("wsb worktree {target}");
    let mut placed = Vec::new();
    place_again(&main, &path, "", &branch, &reason, &clones.iter().map(|c| (c.member.path.clone(), c.branch.clone(), c.commit.clone())).collect::<Vec<_>>(), &mut placed)?;

    // What was moved aside goes when it holds nothing the new checkout does
    // not; otherwise it stays where it is, and says so.
    let mut kept = Vec::new();
    for (shown, checkout, orphan) in moved {
        let differing = differing_files(&orphan, &checkout)?;
        if differing.is_empty() {
            std::fs::remove_dir_all(&orphan).map_err(|e| format!("cannot remove {}: {e}", orphan.display()))?;
        } else {
            kept.push(format!(
                "  {shown}: {} file(s) differ from the commit it was put back on; the old checkout \
                 is kept at {}",
                differing.len(),
                orphan.display()
            ));
        }
    }
    // The folders the orphans were filed under go with the last of them.
    let state = target.project.state_dir();
    let mut folder = Some(aside.as_path());
    while let Some(dir) = folder {
        if dir == state || !dir.starts_with(&state) || !dir.is_dir() {
            break;
        }
        let empty = std::fs::read_dir(dir).map(|mut e| e.next().is_none()).unwrap_or(false);
        if !empty {
            break;
        }
        std::fs::remove_dir(dir).map_err(|e| format!("cannot remove {}: {e}", dir.display()))?;
        folder = dir.parent();
    }
    if !kept.is_empty() {
        crate::output::message(
            crate::output::record::Level::Warn,
            "worktree-orphan-kept",
            format!("{target} was repaired; look at what the old checkouts held:\n{}", kept.join("\n")),
        );
    }
    Ok(placed)
}

/// The migration's third step, for the submodules of the checkout at `here`.
fn place_again(
    there: &Path,
    here: &Path,
    prefix: &str,
    worktree_branch: &str,
    reason: &str,
    salvaged: &[(String, Option<String>, String)],
    placed: &mut Vec<Checkout>,
) -> Result<(), String> {
    for (path, pinned) in gitlinks(here)? {
        let shown = format!("{prefix}{path}");
        let repo = there.join(&path);
        let destination = here.join(&path);
        let arg = path_arg(&destination)?.to_string();
        if destination.join(".git").exists() {
            // Already a worktree of main's repository: kept.
            place_again(&repo, &destination, &format!("{shown}/"), worktree_branch, reason, salvaged, placed)?;
            continue;
        }
        if !repo.join(".git").exists() {
            return Err(format!(
                "{shown} is not checked out in main, so its repository cannot be shared; run \
                 `git submodule update --init --recursive` in {} and migrate again",
                there.display()
            ));
        }
        // A registration left by a checkout that was moved aside names this
        // path and would refuse the new one.
        forget_stale_registration(&repo, &destination)?;
        let (branch, commit) = match salvaged.iter().find(|(p, _, _)| p == &shown) {
            Some((_, branch, commit)) => (branch.clone(), commit.clone()),
            None => (Some(worktree_branch.to_string()), pinned),
        };
        match &branch {
            Some(branch) if holder_of(&repo, branch)?.is_none() => {
                if has_local_branch(&repo, branch)? {
                    let tip = git(&repo, &["rev-parse", &format!("refs/heads/{branch}")])?;
                    if tip == commit {
                        git(&repo, &["worktree", "add", "--quiet", &arg, branch])?;
                    } else {
                        // Main's repository already has this branch, at
                        // another commit: the clone's is kept in the
                        // salvage refs, and the checkout is put on the
                        // clone's commit, detached, for somebody to decide.
                        git(&repo, &["worktree", "add", "--quiet", "--detach", &arg, &commit])?;
                    }
                } else {
                    git(&repo, &["worktree", "add", "--quiet", "-b", branch, &arg, &commit])?;
                }
            }
            _ => {
                git(&repo, &["worktree", "add", "--quiet", "--detach", &arg, &commit])?;
            }
        }
        git(&repo, &["worktree", "lock", "--reason", reason, &arg])?;
        placed.push(Checkout {
            path: shown.clone(),
            branch: branch_of(&destination)?,
            commit: head_of(&destination)?,
        });
        place_again(&repo, &destination, &format!("{shown}/"), worktree_branch, reason, salvaged, placed)?;
    }
    Ok(())
}

/// The files under `old` that `new` does not hold the same, by path —
/// `.git` entries aside, which name repositories rather than hold work.
fn differing_files(old: &Path, new: &Path) -> Result<Vec<PathBuf>, String> {
    let mut differing = Vec::new();
    let mut pending = vec![PathBuf::new()];
    while let Some(relative) = pending.pop() {
        let dir = old.join(&relative);
        let entries = std::fs::read_dir(&dir).map_err(|e| format!("cannot read {}: {e}", dir.display()))?;
        for entry in entries {
            let entry = entry.map_err(|e| format!("cannot read {}: {e}", dir.display()))?;
            if entry.file_name() == ".git" {
                continue;
            }
            let inside = relative.join(entry.file_name());
            let kind = entry.file_type().map_err(|e| format!("cannot read {}: {e}", entry.path().display()))?;
            if kind.is_dir() {
                pending.push(inside);
            } else {
                let theirs = new.join(&inside);
                let same = theirs.is_file()
                    && std::fs::read(entry.path()).map_err(|e| e.to_string())?
                        == std::fs::read(&theirs).map_err(|e| e.to_string())?;
                if !same {
                    differing.push(inside);
                }
            }
        }
    }
    differing.sort();
    Ok(differing)
}

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