wsb 0.253.6349

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
//! Undoing what a render did, without undoing what you did.
//!
//! A generated file in a working tree is two edits stacked on one commit: what
//! the renderer wrote, and what a person then changed by hand. `git checkout`
//! throws away both. Deleting the file and re-rendering throws away the second
//! one. Neither is what somebody means by "discard the update".
//!
//! So the two are separated. Three texts are known:
//!
//!   * `head` — the file as committed.
//!   * `rendered` — what the renderer produces. It reads the TEMPLATE, never
//!     the output, so this is the renderer's whole contribution and is the
//!     same whatever state the working copy is in.
//!   * `worktree` — the file as it stands.
//!
//! What the render contributed is the difference between `head` and
//! `rendered`. Undoing exactly that, and nothing else, is a three-way merge
//! with `rendered` as the base: where only the render differs from it, take
//! `head`; where only the person differs, keep theirs; where both touched the
//! same lines, there is no answer that is certainly right — so theirs is kept
//! and the file is REPORTED as one this could not fully undo.

use crate::refactor::textdiff;

/// What became of one file.
#[derive(Debug, Clone, PartialEq)]
pub struct Discarded {
    /// The text to write back.
    pub content: String,
    /// Regions the render changed that were undone.
    pub reverted: usize,
    /// Regions where the render and a person changed the same lines, so the
    /// person's version was kept.
    pub kept: usize,
}

impl Discarded {
    /// Whether anything at all changed.
    pub fn is_noop(&self) -> bool {
        self.reverted == 0
    }
}

/// Undo the render's contribution to `worktree`, keeping everything else.
///
/// `rendered` is the base because it is what produced the file; `head` is the
/// state to return those regions to.
pub fn discard_render(head: &str, rendered: &str, worktree: &str) -> Result<Discarded, String> {
    // Nothing was contributed: the render produces what is already committed,
    // so there is nothing of its to take back.
    if head == rendered {
        return Ok(Discarded {
            content: worktree.to_string(),
            reverted: 0,
            kept: 0,
        });
    }

    // The working copy is exactly what the render wrote, untouched by anybody.
    // The whole file goes back, and no merge is needed to know that.
    if worktree == rendered {
        return Ok(Discarded {
            content: head.to_string(),
            reverted: 1,
            kept: 0,
        });
    }

    merge(head, rendered, worktree)
}

/// A three-way merge of `head` and `worktree` over the base `rendered`.
///
/// The classic diff3 shape: each side is diffed against the base, the changed
/// regions are lined up, and every region is decided on its own. Deciding the
/// file as a whole would sacrifice one side's edit to the other's whenever
/// both touched it anywhere.
fn merge(head: &str, rendered: &str, worktree: &str) -> Result<Discarded, String> {
    let base = lines(rendered);
    let ours = lines(head);
    let theirs = lines(worktree);

    let head_changes = changes(&base, &ours)?;
    let work_changes = changes(&base, &theirs)?;

    let mut content = String::new();
    let mut reverted = 0;
    let mut kept = 0;

    let mut at = 0usize; // position in the base
    let mut h = 0usize; // next unconsidered head change
    let mut w = 0usize; // next unconsidered worktree change

    while at < base.len() || h < head_changes.len() || w < work_changes.len() {
        let next_head = head_changes.get(h).map(|c| c.base_start);
        let next_work = work_changes.get(w).map(|c| c.base_start);
        let next = match (next_head, next_work) {
            (Some(a), Some(b)) => a.min(b),
            (Some(a), None) => a,
            (None, Some(b)) => b,
            (None, None) => base.len(),
        };

        // Everything before the next change is untouched by either side.
        while at < next && at < base.len() {
            content.push_str(base[at]);
            at += 1;
        }
        if h >= head_changes.len() && w >= work_changes.len() {
            while at < base.len() {
                content.push_str(base[at]);
                at += 1;
            }
            break;
        }

        // One region, seeded from whichever change comes first and widened
        // only while another genuinely OVERLAPS it. Two changes that merely
        // sit next to each other — the render on one line, a person on the
        // next — are two independent decisions, and merging them would
        // sacrifice one to the other.
        let seed = match (head_changes.get(h), work_changes.get(w)) {
            (Some(a), Some(b)) if b.base_start < a.base_start => (b.base_start, b.base_end),
            (Some(a), _) => (a.base_start, a.base_end),
            (None, Some(b)) => (b.base_start, b.base_end),
            (None, None) => break,
        };
        let (lo, mut hi) = seed;
        let mut touched_head = false;
        let mut touched_work = false;

        // A zero-width change is an insertion: it occupies no base line, so
        // "overlap" for it means starting at the very same point.
        let overlaps = |c: &Change, lo: usize, hi: usize| {
            c.base_start < hi || (hi == lo && c.base_start == lo)
        };

        loop {
            let mut grew = false;
            while let Some(change) = head_changes.get(h).copied() {
                if !overlaps(&change, lo, hi) {
                    break;
                }
                if change.base_end > hi {
                    hi = change.base_end;
                    grew = true;
                }
                touched_head = true;
                h += 1;
            }
            while let Some(change) = work_changes.get(w).copied() {
                if !overlaps(&change, lo, hi) {
                    break;
                }
                if change.base_end > hi {
                    hi = change.base_end;
                    grew = true;
                }
                touched_work = true;
                w += 1;
            }
            if !grew {
                break;
            }
        }

        let head_text = slice_for(&ours, &head_changes, lo, hi);
        let work_text = slice_for(&theirs, &work_changes, lo, hi);

        match (touched_head, touched_work) {
            // Only the render moved these lines away from what was committed.
            // Putting the committed lines back is exactly the undo.
            (true, false) => {
                content.push_str(&head_text);
                reverted += 1;
            }
            // Only a person did. Theirs stands, untouched.
            (false, true) => content.push_str(&work_text),
            (true, true) => {
                if head_text == work_text {
                    // Both arrived at the same text, so there is nothing to
                    // choose between and nothing to report.
                    content.push_str(&head_text);
                } else {
                    // No version is certainly right. Nothing is guessed: the
                    // person's lines are kept and the file is REPORTED as one
                    // this could not fully undo.
                    content.push_str(&work_text);
                    kept += 1;
                }
            }
            (false, false) => unreachable!("a region exists because a side changed it"),
        }
        at = hi;
    }

    Ok(Discarded {
        content,
        reverted,
        kept,
    })
}

/// One side's replacement for a run of base lines.
#[derive(Debug, Clone, Copy)]
struct Change {
    base_start: usize,
    base_end: usize,
    side_start: usize,
    side_end: usize,
}

impl Change {
    /// How many lines this side gained here, negative when it lost some.
    fn length_difference(&self) -> isize {
        (self.side_end as isize - self.side_start as isize)
            - (self.base_end as isize - self.base_start as isize)
    }
}

/// Where one side differs from the base, as aligned runs.
fn changes(base: &[&str], side: &[&str]) -> Result<Vec<Change>, String> {
    let script = textdiff::script(base, side)?;
    let mut out = Vec::new();
    let (mut b, mut s) = (0usize, 0usize);
    let mut index = 0;
    while index < script.len() {
        if script[index] == textdiff::Edit::Keep {
            b += 1;
            s += 1;
            index += 1;
            continue;
        }
        let base_start = b;
        let side_start = s;
        while index < script.len() && script[index] != textdiff::Edit::Keep {
            match &script[index] {
                textdiff::Edit::Remove => b += 1,
                textdiff::Edit::Insert(_) => s += 1,
                textdiff::Edit::Keep => unreachable!("the loop guards on Keep"),
            }
            index += 1;
        }
        out.push(Change {
            base_start,
            base_end: b,
            side_start,
            side_end: s,
        });
    }
    Ok(out)
}

/// The text one side has where the base has `lo..hi`.
///
/// Outside a change the two run in step, so a base position maps to a side
/// position by the running difference in length of everything before it. The
/// two ends map DIFFERENTLY: an insertion occupies no base line, so a region
/// that is one point wide still has text in it, and mapping both ends the same
/// way would return nothing.
fn slice_for(side: &[&str], changes: &[Change], lo: usize, hi: usize) -> String {
    let start = map_start(changes, lo);
    let end = map_end(changes, hi);
    if start >= end {
        return String::new();
    }
    side[start.min(side.len())..end.min(side.len())].concat()
}

/// The side position where a region BEGINS.
///
/// A change starting exactly here has not been passed yet — its text is inside
/// the region, not before it.
fn map_start(changes: &[Change], base_position: usize) -> usize {
    let mut shift: isize = 0;
    for change in changes {
        if change.base_end <= base_position && change.base_start < base_position {
            shift += change.length_difference();
        } else if change.base_start < base_position && change.base_end > base_position {
            return change.side_start;
        }
    }
    (base_position as isize + shift).max(0) as usize
}

/// The side position where a region ENDS.
///
/// A change ending exactly here has been passed, insertion or not, so its text
/// falls inside the region.
fn map_end(changes: &[Change], base_position: usize) -> usize {
    let mut shift: isize = 0;
    for change in changes {
        if change.base_end <= base_position {
            shift += change.length_difference();
        } else if change.base_start < base_position {
            return change.side_end;
        }
    }
    (base_position as isize + shift).max(0) as usize
}

/// Lines with their terminators, so a trailing newline is a real difference.
fn lines(text: &str) -> Vec<&str> {
    let mut out = Vec::new();
    let mut rest = text;
    while !rest.is_empty() {
        match rest.find('\n') {
            Some(at) => {
                out.push(&rest[..=at]);
                rest = &rest[at + 1..];
            }
            None => {
                out.push(rest);
                rest = "";
            }
        }
    }
    out
}

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

// ── The command ───────────────────────────────────────────────────────────────

use crate::prelude::*;

/// What happened to one file.
#[derive(Debug, Clone, PartialEq)]
pub enum Outcome {
    /// The render's contribution was taken back in full.
    Reverted,
    /// Taken back where it could be, and `kept` regions left alone because a
    /// person had changed the same lines.
    Partial { kept: usize },
    /// The render contributed nothing to this file.
    Untouched,
    /// The render created it and nothing else was added, so it is gone.
    Removed,
    /// It is not in the commit at all and somebody has since edited it, so
    /// there is no committed state to return the render's part to.
    Unresolvable { reason: String },
}

/// One file's result.
#[derive(Debug, Clone)]
pub struct FileOutcome {
    pub path: PathBuf,
    pub outcome: Outcome,
}

/// What a discard would do, or did.
#[derive(Debug, Clone, Default)]
pub struct Report {
    pub files: Vec<FileOutcome>,
}

impl Report {
    pub fn changed(&self) -> usize {
        self.files
            .iter()
            .filter(|f| {
                matches!(
                    f.outcome,
                    Outcome::Reverted | Outcome::Partial { .. } | Outcome::Removed
                )
            })
            .count()
    }

    /// Files where a person's edit stood in the way of a full undo.
    pub fn partial(&self) -> impl Iterator<Item = &FileOutcome> {
        self.files
            .iter()
            .filter(|f| matches!(f.outcome, Outcome::Partial { .. }))
    }
}

/// Take back what `wsb update` wrote, and nothing else.
///
/// Every file the update would write is looked at. This is deliberately NOT
/// `wsb discard`, which throws away a working tree: this touches only the
/// files an update produces, and inside each of them only the lines the
/// production contributed.
///
/// `dry_run` computes the whole answer and writes none of it.
pub fn run(project_root: &Path, dry_run: bool) -> Result<Report> {
    run_with_cache(project_root, dry_run, &crate::state::RunCache::new())
}

/// The same, reusing what a run has already read.
///
/// `wsb local off` takes a render back in every repository of the fleet, which
/// is the same 34 passes over the same workspace that putting it there was.
pub fn run_with_cache(
    project_root: &Path,
    dry_run: bool,
    cache: &Arc<crate::state::RunCache>,
) -> Result<Report> {
    let outputs = planned_outputs_with_cache(project_root, cache)?;
    let mut report = Report::default();

    for (path, rendered) in outputs {
        let worktree = match std::fs::read_to_string(&path) {
            Ok(text) => Some(text),
            // Nothing on disk: the update would have created it, and there is
            // nothing of anybody's to preserve.
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
            Err(error) => {
                report.files.push(FileOutcome {
                    path,
                    outcome: Outcome::Unresolvable {
                        reason: error.to_string(),
                    },
                });
                continue;
            }
        };
        let Some(worktree) = worktree else {
            report.files.push(FileOutcome {
                path,
                outcome: Outcome::Untouched,
            });
            continue;
        };

        let head = head_text(&path);
        match head {
            // The file is committed. Undo the render's part of it.
            Some(head) => {
                let merged = discard_render(&head, &rendered, &worktree)
                    .map_err(|error| anyhow::anyhow!("{}: {error}", path.display()))?;
                if merged.content == worktree {
                    report.files.push(FileOutcome {
                        path,
                        outcome: Outcome::Untouched,
                    });
                    continue;
                }
                if !dry_run {
                    std::fs::write(&path, &merged.content).with_context(|| {
                        format!("cannot write {} back", path.display())
                    })?;
                    // A manifest put back is a manifest whose version changed,
                    // and the lock that governs it has to agree — the rule is
                    // about changing a manifest, not about rendering one.
                    // Skipping it here left the lock AHEAD of the manifest
                    // after every `wsb local off`, and `cargo build --locked`
                    // refused the whole workspace: in the tree, and in every
                    // target a run staged it into.
                    //
                    // A manifest put back exactly as committed takes its lock
                    // back exactly as committed too: the committed pair is the
                    // pair known to agree. Relocked instead, npm wrote a lock
                    // of its own making — floom-uikit's then differed from the
                    // committed one, the checkpoint committed it, and the
                    // commit moved floom-uikit's version on every checkpoint.
                    // A manifest that keeps somebody's edits has no committed
                    // lock to match, and is relocked from what it now says. So
                    // is a Cargo.lock, which governs every member of its cargo
                    // workspace: one member put back says nothing about the
                    // others.
                    let own_lock = path.file_name().is_some_and(|name| name != "Cargo.toml");
                    let committed_lock = (merged.content == head && own_lock)
                        .then(|| crate::state::wstemplate::lockfile_followed_by(&path))
                        .flatten()
                        .and_then(|lock| head_text(&lock).map(|text| (lock, text)));
                    match committed_lock {
                        Some((lock, text)) => std::fs::write(&lock, text)
                            .with_context(|| format!("cannot put {} back", lock.display()))?,
                        None => {
                            crate::state::wstemplate::follow_into_lockfile(&path, &merged.content)?;
                        }
                    }
                }
                report.files.push(FileOutcome {
                    path,
                    outcome: if merged.kept > 0 {
                        Outcome::Partial { kept: merged.kept }
                    } else {
                        Outcome::Reverted
                    },
                });
            }
            // Not committed. The render created it, so the whole file is the
            // render's — unless somebody has since changed it, and then there
            // is no committed state to return their file to. Refused rather
            // than guessed: deleting it would take their work with it.
            None => {
                if worktree == rendered {
                    if !dry_run {
                        std::fs::remove_file(&path).with_context(|| {
                            format!("cannot remove {}", path.display())
                        })?;
                    }
                    report.files.push(FileOutcome {
                        path,
                        outcome: Outcome::Removed,
                    });
                } else {
                    report.files.push(FileOutcome {
                        path,
                        outcome: Outcome::Unresolvable {
                            reason: "created by the update and edited since, with no committed \
                                     version to return the update's part to"
                                .to_string(),
                        },
                    });
                }
            }
        }
    }

    Ok(report)
}

/// Every file `wsb update` would write, with what it would put in it.
///
/// Produced, never written. Rendering over the tree first would destroy the
/// very thing being measured.
pub fn planned_outputs_with_cache(
    project_root: &Path,
    cache: &Arc<crate::state::RunCache>,
) -> Result<Vec<(PathBuf, String)>> {
    let config = crate::state::StateConfig::load(project_root)?;
    let workspace_state = crate::workspace_state::WorkspaceState::load(project_root)?;
    let mut version_info =
        crate::state::state_common::calculate_workspace_version_cached(project_root, cache)?;
    version_info.full_version = workspace_state.format_version(&version_info, None);

    let mut outputs: Vec<(PathBuf, String)> = Vec::new();

    // The version file, which an update writes whole.
    if !config.version_file.is_empty() {
        let path = if Path::new(&config.version_file).is_absolute() {
            PathBuf::from(&config.version_file)
        } else {
            project_root.join(&config.version_file)
        };
        outputs.push((path, format!("{}\n", version_info.full_version)));
    }

    let template_manager = crate::state::TemplateManager::new(&workspace_state)?;
    outputs.extend(
        template_manager
            .produce_all_templates(&version_info, workspace_state.project_name.as_deref()),
    );

    let entry = workspace_state.effective_wstemplate_entry();
    let engine = crate::state::wstemplate::WstemplateEngine::with_cache(
        version_info.clone(),
        workspace_state.project_name.clone(),
        entry.alias,
        project_root.to_path_buf(),
        entry.root,
        Arc::clone(cache),
    );
    for produced in engine.produce_relevant()? {
        outputs.push((produced.output_path, produced.content));
    }

    Ok(outputs)
}

/// Every generated file in the fleet that currently stands exactly as a render
/// would write it.
///
/// This is what lets a status report separate "the renderer did this" from "a
/// person did this". A working tree under `wsb local on` carries a rendered
/// manifest and a bumped `version.txt` in nearly every repository — 75 files in
/// this workspace — and git cannot tell them from an edit: both are simply
/// modified paths. Reported as changes they bury the handful of files somebody
/// actually touched, which is the state `wsb status` was unreadable in.
///
/// The answer is not a list of filenames or a pattern over them. It is the
/// render itself, produced in memory and compared: a file equal to what the
/// renderer would write is the renderer's, and one that differs is not — so a
/// manifest somebody edited by hand still reports, and so does a version file
/// they wrote a different number into.
///
/// # Both renders, because a tree carries one of two
///
/// A template switches whole sections on the workspace's local variable
/// (declared in `.wsb/stamp.json`), and what renders a
/// tree is the environment the render RAN IN — there is no declaration and no
/// toggle to read afterwards. A classifier that produced one render would
/// answer for one of the two states and call the other an edit: rendering
/// without the variable against a tree that is `local` emits the published-tag
/// branch, which matches nothing on disk, and every local manifest in the fleet
/// reports as somebody's work. That is the whole bug this function existed to
/// fix, reintroduced one layer down.
///
/// So both renders are produced and a file matching EITHER is the renderer's.
/// Nothing is lost by accepting both: the two differ only where the template
/// switches, and a person's edit matches neither. It is also the honest answer
/// to a question with two legitimate answers — `wsb local status` is the command
/// that says WHICH state the tree is in, and this one only needs to know that
/// the renderer wrote the file rather than a person.
///
/// Read-only, and it must stay that way: producing the render over the tree
/// would destroy the thing being measured. The variable is set for that render
/// alone, never in the process's environment, where every concurrent render
/// would see it and whatever runs next would inherit it.
///
/// Paths are absolute, because callers match them against repositories at
/// every depth of the nesting tree and a relative path means nothing without
/// the repository it was relative to.
pub fn rendered_as_written(
    workspace_root: &Path,
    cache: &Arc<crate::state::RunCache>,
) -> Result<std::collections::HashSet<PathBuf>> {
    let published = planned_fleet_outputs(workspace_root, cache)?;
    // The same walk with the local branch selected, for a workspace that has
    // one: a run of its own, with the variable set, since the memoised renders
    // in the first were produced without it and would be handed back unchanged.
    let local = match super::stamp_config::StampConfig::load(workspace_root)?
        .and_then(|config| config.local)
    {
        Some(local) => planned_fleet_outputs(
            workspace_root,
            &crate::state::RunCache::with_variable(&local.variable, "1"),
        )?,
        None => Vec::new(),
    };

    let mut settled = std::collections::HashSet::new();
    for (path, rendered) in published.into_iter().chain(local) {
        if settled.contains(&path) {
            continue;
        }
        if std::fs::read_to_string(&path).is_ok_and(|worktree| worktree == rendered) {
            settled.insert(path);
        }
    }
    Ok(settled)
}

/// Every file a render would write across the fleet, with what it would put in
/// it, under whatever environment this is called in.
fn planned_fleet_outputs(
    workspace_root: &Path,
    cache: &Arc<crate::state::RunCache>,
) -> Result<Vec<(PathBuf, String)>> {
    use crate::fleet::git::tree::SubmoduleTree;
    use std::collections::HashMap;

    let tree = SubmoduleTree::discover_allow_empty(workspace_root).map_err(|e| {
        anyhow::anyhow!(
            "discovering submodules under {}: {e}",
            workspace_root.display()
        )
    })?;

    // Every repository that could own a template, the superproject included.
    let mut repositories: Vec<PathBuf> = tree
        .bottom_up()
        .iter()
        .map(|node| node.abs_path.clone())
        .collect();
    repositories.push(workspace_root.to_path_buf());

    let boundaries: Vec<PathBuf> = tree
        .top_down()
        .iter()
        .map(|node| node.abs_path.clone())
        .collect();

    // One render per path, chosen the way `wsb local status` chooses it.
    //
    // A path is produced by more than one pass: a submodule renders its own
    // `version.txt`, and so does the parent whose walk reaches it. Those two
    // passes do not agree — the parent computes the child's version from its
    // own state — so letting a later pass overwrite an earlier one settles the
    // path against a render that never touched it. `cartridges/pdfcartridge`
    // reported its `Cargo.toml` as the render and its `version.txt` as an edit
    // for exactly that reason, splitting a pair that the same update wrote.
    //
    // The owning repository is reached first by the bottom-up walk, so the
    // first render of a path is the one that answers for it.
    let mut landing: HashMap<PathBuf, String> = HashMap::new();

    for repository in &repositories {
        // Only the repositories that own a template render anything. Asking a
        // repository that owns none walks its whole subtree and answers for
        // files belonging to the submodules inside it, which is how a parent's
        // pass came to overwrite its children's.
        if !owns_templates(repository, &boundaries, cache)? {
            continue;
        }
        for (path, rendered) in planned_outputs_with_cache(repository, cache)? {
            landing.entry(path).or_insert(rendered);
        }
    }

    Ok(landing.into_iter().collect())
}

/// Whether this repository owns any `.wstemplate`, and so renders anything.
///
/// Ownership follows git's boundary: a template inside a nested submodule
/// belongs to that submodule, never to the repository containing it. Only the
/// boundaries NESTED INSIDE this one exclude a template — testing against every
/// boundary would exclude a repository's own ancestors, and since
/// `capdag/capdag-rs` starts with `capdag`, every nested repository in the
/// fleet would report owning nothing.
pub fn owns_templates(
    repository: &Path,
    boundaries: &[PathBuf],
    cache: &crate::state::RunCache,
) -> Result<bool> {
    let nested: Vec<&PathBuf> = boundaries
        .iter()
        .filter(|boundary| boundary.as_path() != repository && boundary.starts_with(repository))
        .collect();
    // The same scan the render engine does for its own scan root, through the
    // same memo, so a fleet whose projects share one scan root pays for it once.
    let templates = cache.templates_under(repository, || {
        crate::search_backend::discover_files_with_suffix(repository, ".wstemplate")
    })?;
    Ok(templates
        .iter()
        .any(|template| !nested.iter().any(|inner| template.starts_with(inner))))
}

/// One file as its own repository has it committed.
///
/// `None` when it is not tracked there. Asked of the repository the file is
/// IN, not of the workspace root: this tree is submodules all the way down,
/// and a path's committed state lives in whichever of them owns it.
/// One file as it stands in the last commit, or `None` when it is not
/// committed there.
pub fn head_text(path: &Path) -> Option<String> {
    let directory = path.parent()?;
    let repo = crate::git_backend::repo_root(directory).ok()?;
    let relative = path.strip_prefix(&repo).ok()?;
    let output = crate::git_backend::git_command()
        .arg("-C")
        .arg(&repo)
        .arg("show")
        .arg(format!("HEAD:{}", relative.display()))
        .output()
        .ok()?;
    if !output.status.success() {
        return None;
    }
    Some(String::from_utf8_lossy(&output.stdout).into_owned())
}