OXYGENHUB

Projects coming from Git: .gitignore honoured, nested Git repositories named (closes #43, closes #44) d4dbab97e948

- A directory's .gitignore and .ignore files are read with its
  .oxyignore, below it: .oxyignore can bring back with ! what they leave
  out. Ignore rules apply to untracked paths only, so the walk no longer
  matches the base's files against them (on the Linux tree, with its 405
  .gitignore files, that kept status at its speed), while a file added
  but not committed is still dropped by a rule written after it.
- A folder holding .git or .oxy that is not a subrepo is now named by
  status (clean or not), commit and status --json (untracked_nested),
  with how to track its files or keep it a subrepo.
- The clean check finds tracked files the ignore rules hide by merging
  the sorted listing with the manifest, and checks them in parallel,
  rather than stat-ing every manifest entry.

Spec and guide say so.
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-01 00:29 UTC
Signature
✔ signed by Andy McCall
Hash
d4dbab97e9481a95cd2b7bb47747f967e3eee85da7397703a77692aa688f34a9
Depends on
135212de5370 1966f5db15ee 4dee277c327a 50a046e439e9 75167672ebcd 76740263089c 957fee30826e 982864068a59 9ac1fbe211b4 c6aa5bd9d1c8 cdb5beba7f05 da868d5d2646 e266b7d4065c eaf3e9068180 ee06a6c557b7 f9466021286a fce44ae9ad29 ff0af1ee299d
Depended on by
232aabb5452d 64dc5197f9df 4afe288b7946 44f1ebe7aa5b 9b723f69b032 b689779e9e74 7505194116e7 80677b7a3513 236f35c0baba 0389c3e82f6c 3604179679eb 2bf22305bc48 fd280228bdc2 69692f14b80f
In bookmarks
agents-in-readme bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
In tags
v0.10.0 v0.11.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.4.3 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
Edits
41 atoms, 0 tree operations, 9 files

Browse the tree at this change

modified crates/oxy-core/src/repo/mod.rs +21 −1
…
202 unchanged lines
203 203
    /// snapshot that follows it to reuse; taken by the next walk and
204 204
    /// dropped whenever the manifest is written.
205 205
    pub(crate) listing_cache: std::cell::RefCell<Option<walk::Listing>>,
206 +
    /// The nested repositories the last walk left out, being neither
207 +
    /// registered nor tracked subrepos (oxygen#43).
208 +
    pub(crate) untracked_nested: std::cell::RefCell<Vec<String>>,
206 209
    root: PathBuf,
207 210
    oxy: PathBuf,
208 211
    store: Store,
…
76 unchanged lines
285 288
            git_export_skipped: Default::default(),
286 289
            manifest_cache: Default::default(),
287 290
            listing_cache: Default::default(),
291 +
            untracked_nested: Default::default(),
288 292
            root,
289 293
            oxy,
290 294
            store,
…
46 unchanged lines
337 341
            git_export_skipped: Default::default(),
338 342
            manifest_cache: Default::default(),
339 343
            listing_cache: Default::default(),
344 +
            untracked_nested: Default::default(),
340 345
            root,
341 346
            oxy,
342 347
            store,
…
68 unchanged lines
411 416
                    git_export_skipped: Default::default(),
412 417
                    manifest_cache: Default::default(),
413 418
                    listing_cache: Default::default(),
419 +
                    untracked_nested: Default::default(),
414 420
                    root: d.to_path_buf(),
415 421
                    oxy: shared,
416 422
                    store,
…
23 unchanged lines
440 446
                    git_export_skipped: Default::default(),
441 447
                    manifest_cache: Default::default(),
442 448
                    listing_cache: Default::default(),
449 +
                    untracked_nested: Default::default(),
443 450
                    root: d.to_path_buf(),
444 451
                    oxy,
445 452
                    store,
…
651 unchanged lines
1097 1104
        )
1098 1105
    }
1099 1106
1107 +
    /// Folders holding a repository of their own that the last look at the
1108 +
    /// working copy left out, not being subrepos (oxygen#43).
1109 +
    pub fn untracked_nested(&self) -> Vec<String> {
1110 +
        self.untracked_nested.borrow().clone()
1111 +
    }
1112 +
1100 1113
    /// Attributes from a materialized state's `.oxyattributes` files.
1101 1114
    pub fn state_attributes(snapshot: &Snapshot) -> crate::attributes::Attributes {
1102 1115
        crate::attributes::Attributes::from_files(snapshot.files.iter().filter_map(|(p, f)| {
…
261 unchanged lines
1364 1377
            nested,
1365 1378
        } = match self.listing_cache.take() {
1366 1379
            Some(l) => l,
1367 -
            None => walk::list(&self.root, &in_scope)?,
1380 +
            // Ignore rules apply to untracked paths only, so the base's
1381 +
            // files are not matched against them (an added file is: a rule
1382 +
            // written after it was snapshotted leaves it out again).
1383 +
            None => walk::list_knowing(&self.root, &in_scope, &|p: &str| tracked.contains_key(p))?,
1368 1384
        };
1369 1385
        for (rel, target) in symlinks {
1370 1386
            out.insert(rel, FsEntry::new(NodeKind::Symlink, false, target));
…
54 unchanged lines
1425 1441
                self.clear_removed_subrepo(rel);
1426 1442
            }
1427 1443
        }
1444 +
        let mut untracked_nested = Vec::new();
1428 1445
        for dir in nested {
1429 1446
            let rel = self.rel_path(&dir);
1430 1447
            if !self.in_scope(&rel) || removed.contains(&rel) {
…
2 unchanged lines
1433 1450
            let registered = modules.entries.get(&rel);
1434 1451
            if registered.is_none() && tracked.get(&rel).map(|(k, _)| *k) != Some(NodeKind::Subrepo)
1435 1452
            {
1453 +
                untracked_nested.push(rel);
1436 1454
                continue;
1437 1455
            }
1438 1456
            let pin = subrepos::read_pin(&dir, registered.and_then(|m| m.bookmark.as_deref()))?;
1439 1457
            out.insert(rel, FsEntry::new(NodeKind::Subrepo, false, pin.encode()));
1440 1458
        }
1459 +
        untracked_nested.sort();
1460 +
        *self.untracked_nested.borrow_mut() = untracked_nested;
1441 1461
        // Tracked paths the ignore rules hid.
1442 1462
        for (rel, (kind, blob)) in tracked {
1443 1463
            if out.contains_key(rel) || !self.in_scope(rel) {
…
486 unchanged lines
modified crates/oxy-core/src/repo/prompt.rs +46 −18
…
479 unchanged lines
480 480
                    .any(|s| p == s || p.starts_with(&format!("{s}/")))
481 481
        };
482 482
        // A working copy that cannot be listed counts as changed.
483 -
        let Ok(listing) = super::walk::list(&self.root, &in_scope) else {
483 +
        // Known files are not matched against the ignore rules: with no
484 +
        // uncommitted work (the clean check), the manifest's files are the
485 +
        // base's, and a prompt's answer is the same either way.
486 +
        let known = |p: &str| manifest.entries.contains_key(p);
487 +
        let Ok(listing) = super::walk::list_knowing(&self.root, &in_scope, &known) else {
484 488
            return Ok(Some(true));
485 489
        };
486 490
        let differs = || -> Result<bool> {
…
24 unchanged lines
511 515
            if seen == manifest.entries.len() {
512 516
                return Ok(false);
513 517
            }
514 -
            // Manifest entries the walk skipped: tracked files that ignore rules
515 -
            // hide. They count as present when still on disk and unchanged.
516 -
            for (rel, me) in &manifest.entries {
517 -
                let path = self.root.join(rel);
518 +
            // Manifest entries the walk skipped: tracked files that ignore
519 +
            // rules hide (Linux tracks some its own .gitignore names). Found
520 +
            // by merging the two sorted lists; they count as present when
521 +
            // still on disk and unchanged, checked on every core.
522 +
            let mut listed: Vec<&str> = listing
523 +
                .files
524 +
                .iter()
525 +
                .map(|(r, _, _)| r.as_str())
526 +
                .chain(listing.symlinks.iter().map(|(r, _)| r.as_str()))
527 +
                .collect();
528 +
            listed.sort_unstable();
529 +
            let mut at = listed.iter().peekable();
530 +
            let skipped: Vec<(&String, &ManifestEntry)> = manifest
531 +
                .entries
532 +
                .iter()
533 +
                .filter(|(rel, _)| {
534 +
                    while at.next_if(|l| **l < rel.as_str()).is_some() {}
535 +
                    at.peek().is_none_or(|l| **l != rel.as_str())
536 +
                })
537 +
                .collect();
538 +
            let root = &self.root;
539 +
            Ok(skipped.par_iter().any(|(rel, me)| {
540 +
                let path = root.join(rel.as_str());
518 541
                let Ok(m) = fs::symlink_metadata(&path) else {
519 -
                    return Ok(true);
542 +
                    return true;
520 543
                };
521 -
                if m.file_type().is_symlink() {
522 -
                    continue;
523 -
                }
524 -
                if m.len() != me.size
525 -
                    || super::is_executable(&m) != me.exec
526 -
                    || (mtime_ns(&m) != me.mtime_ns
527 -
                        && *blake3::hash(&fs::read(&path)?).as_bytes() != me.hash)
528 -
                {
529 -
                    return Ok(true);
530 -
                }
531 -
            }
532 -
            Ok(false)
544 +
                !m.file_type().is_symlink()
545 +
                    && (m.len() != me.size
546 +
                        || super::is_executable(&m) != me.exec
547 +
                        || (mtime_ns(&m) != me.mtime_ns
548 +
                            && !fs::read(&path)
549 +
                                .is_ok_and(|b| *blake3::hash(&b).as_bytes() == me.hash)))
550 +
            }))
533 551
        };
534 552
        let dirty = differs()?;
553 +
        if !dirty {
554 +
            // Nested repositories the manifest has no entry for are not
555 +
            // subrepos: nothing in them is tracked (oxygen#43).
556 +
            *self.untracked_nested.borrow_mut() = listing
557 +
                .nested
558 +
                .iter()
559 +
                .map(|d| self.rel_path(d))
560 +
                .filter(|rel| !manifest.entries.contains_key(rel))
561 +
                .collect();
562 +
        }
535 563
        if dirty && keep {
536 564
            *self.listing_cache.borrow_mut() = Some(listing);
537 565
        }
…
78 unchanged lines
modified crates/oxy-core/src/repo/snapshot.rs +7 −2
…
58 unchanged lines
59 59
    pub contributors: Vec<ChangeHash>,
60 60
    /// The `.oxyattributes` in force on disk, from the files the walk read.
61 61
    pub(crate) attributes: crate::attributes::Attributes,
62 +
    /// Folders holding a repository of their own (`.git` or `.oxy`) that
63 +
    /// are not subrepos, so nothing in them is tracked (oxygen#43).
64 +
    pub untracked_nested: Vec<String>,
62 65
}
63 66
64 67
pub fn is_binary(content: &[u8]) -> bool {
…
433 unchanged lines
498 501
            resolves,
499 502
            contributors,
500 503
            attributes,
504 +
            untracked_nested: self.untracked_nested.borrow().clone(),
501 505
        })
502 506
    }
503 507
…
89 unchanged lines
593 597
            ));
594 598
        }
595 599
        Ok(format!(
596 -
            "{{\"bookmark\":{},\"working_copy\":{},\"heads\":{},\"clean\":{},\"conflicted\":{},\"paths\":{paths},\"conflicts\":{}}}",
600 +
            "{{\"bookmark\":{},\"working_copy\":{},\"heads\":{},\"clean\":{},\"conflicted\":{},\"paths\":{paths},\"conflicts\":{},\"untracked_nested\":{}}}",
597 601
            json_str(&self.config.bookmark),
598 602
            json_str(&snap.wc.change_id.to_hex()),
599 603
            heads.len(),
600 604
            snap.statuses.is_empty(),
601 605
            base_snapshot.is_conflicted(),
602 -
            json_array(conflicts)
606 +
            json_array(conflicts),
607 +
            json_array(snap.untracked_nested.iter().map(|p| json_str(p)))
603 608
        ))
604 609
    }
605 610
…
1985 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +23 −0
…
6395 unchanged lines
6396 6396
        "{err}"
6397 6397
    );
6398 6398
}
6399 +
6400 +
#[test]
6401 +
fn an_ignore_rule_drops_an_added_file_but_not_a_tracked_one() {
6402 +
    hermetic();
6403 +
    let dir = tempfile::tempdir().unwrap();
6404 +
    let root = dir.path();
6405 +
    let repo = Repo::init(root).unwrap();
6406 +
    write(root, "a.txt", b"a\n");
6407 +
    write(root, "kept.log", b"tracked\n");
6408 +
    repo.commit("first", &[]).unwrap();
6409 +
    write(root, "build.log", b"made\n");
6410 +
    let snap = repo.snapshot_status("status").unwrap();
6411 +
    assert!(snap.statuses.iter().any(|s| s.path == "build.log"));
6412 +
    // A rule written afterwards leaves the added file out again, while the
6413 +
    // committed one stays tracked.
6414 +
    write(root, ".gitignore", b"*.log\n");
6415 +
    let snap = repo.snapshot_status("status").unwrap();
6416 +
    let paths: Vec<&str> = snap.statuses.iter().map(|s| s.path.as_str()).collect();
6417 +
    assert_eq!(paths, vec![".gitignore"], "{paths:?}");
6418 +
    write(root, "kept.log", b"tracked, edited\n");
6419 +
    let snap = repo.snapshot_status("status").unwrap();
6420 +
    assert!(snap.statuses.iter().any(|s| s.path == "kept.log"));
6421 +
}
modified crates/oxy-core/src/repo/walk.rs +35 −6
1 1
//! Listing the working copy (spec §12.3): every file and symlink under the
2 2
//! root, found in parallel. `.oxy` is skipped, and so is a nested
3 3
//! repository (a directory holding `.oxy` or `.git`), which is reported
4 -
//! instead. `.oxyignore` files (and `.ignore` files, as before) exclude
5 -
//! paths with gitignore syntax, the nearest file deciding.
4 +
//! instead. `.oxyignore` files exclude paths with gitignore syntax, the
5 +
//! nearest file deciding; `.gitignore` and `.ignore` files count too, below
6 +
//! `.oxyignore` in the same directory (oxygen#44), so a project coming from
7 +
//! Git does not commit what Git ignored, and `.oxyignore` can still say
8 +
//! otherwise with `!`.
6 9
//!
7 10
//! Each directory is read once; a file's stat comes from its directory
8 11
//! entry, and results are gathered a directory at a time, so a tree of
…
22 unchanged lines
31 34
impl Rules {
32 35
    /// The rules for `dir`, adding its own ignore files if it has any.
33 36
    fn enter(&self, dir: &Path, names: &[String]) -> Rules {
34 -
        let files: Vec<&str> = [".ignore", ".oxyignore"]
37 +
        // Later files win: .oxyignore over .ignore over .gitignore.
38 +
        let files: Vec<&str> = [".gitignore", ".ignore", ".oxyignore"]
35 39
            .into_iter()
36 40
            .filter(|f| names.iter().any(|n| n == f))
37 41
            .collect();
…
27 unchanged lines
65 69
66 70
struct Ctx<'a> {
67 71
    in_scope: &'a (dyn Fn(&str) -> bool + Sync),
72 +
    known: &'a (dyn Fn(&str) -> bool + Sync),
68 73
    out: Mutex<Listing>,
69 74
    failed: Mutex<Option<String>>,
70 75
}
…
1 unchanged lines
72 77
/// List the working copy under `root`; files outside `in_scope` (a sparse
73 78
/// checkout's paths) are left out, though every directory is visited.
74 79
pub(crate) fn list(root: &Path, in_scope: &(dyn Fn(&str) -> bool + Sync)) -> Result<Listing> {
80 +
    list_knowing(root, in_scope, &|_| false)
81 +
}
82 +
83 +
/// [`list`], taking files `known` says are tracked without asking the
84 +
/// ignore rules, which apply to untracked paths only: on the Linux tree,
85 +
/// matching every file against its 400 `.gitignore` files cost more than
86 +
/// a manifest lookup.
87 +
pub(crate) fn list_knowing(
88 +
    root: &Path,
89 +
    in_scope: &(dyn Fn(&str) -> bool + Sync),
90 +
    known: &(dyn Fn(&str) -> bool + Sync),
91 +
) -> Result<Listing> {
75 92
    let ctx = Ctx {
76 93
        in_scope,
94 +
        known,
77 95
        out: Mutex::new(Listing::default()),
78 96
        failed: Mutex::new(None),
79 97
    };
…
52 unchanged lines
132 150
        }
133 151
        let Ok(ft) = e.file_type() else { continue };
134 152
        let path = e.path();
135 -
        if rules.ignored(&path, ft.is_dir()) {
136 -
            continue;
137 -
        }
138 153
        let child = if rel.is_empty() {
139 154
            name
140 155
        } else {
141 156
            format!("{rel}/{name}")
142 157
        };
158 +
        if (ft.is_dir() || !(ctx.known)(&child)) && rules.ignored(&path, ft.is_dir()) {
159 +
            continue;
160 +
        }
143 161
        if ft.is_dir() {
144 162
            let rules = rules.clone();
145 163
            s.spawn(move |s| visit(s, path, child, false, rules, ctx));
…
76 unchanged lines
222 240
        write(root, "other/.ignore", "secret\n");
223 241
        write(root, "other/secret", "");
224 242
        write(root, "other/public", "");
243 +
        // .gitignore counts, below .oxyignore in the same directory.
244 +
        write(root, "gitproj/.gitignore", "build/\n*.tmp\nkeep.tmp\n");
245 +
        write(root, "gitproj/.oxyignore", "!keep.tmp\n");
246 +
        write(root, "gitproj/build/out.bin", "");
247 +
        write(root, "gitproj/x.tmp", "");
248 +
        write(root, "gitproj/keep.tmp", "");
249 +
        write(root, "gitproj/src.c", "");
225 250
        // Nested repositories are reported, not entered.
226 251
        write(root, "sub/.oxy/config", "");
227 252
        write(root, "sub/inner.txt", "");
…
6 unchanged lines
234 259
            ".hidden",
235 260
            ".oxyignore",
236 261
            "a.txt",
262 +
            "gitproj/.gitignore",
263 +
            "gitproj/.oxyignore",
264 +
            "gitproj/keep.tmp",
265 +
            "gitproj/src.c",
237 266
            "keep/.oxyignore",
238 267
            "keep/k.log",
239 268
            "other/.ignore",
…
15 unchanged lines
modified docs/guide.md +3 −1
…
47 unchanged lines
48 48
directory, negations with `!`) for files that should never be tracked;
49 49
`/target` and `*.tmp` are typical entries. As in Git, ignore rules only
50 50
affect untracked files: a file already in history stays tracked until you
51 -
delete it.
51 +
delete it. A project's `.gitignore` files are honoured too, so moving a Git
52 +
project to Oxygen does not commit its build output; `.oxyignore` comes
53 +
first, and a `!` rule there brings back what `.gitignore` leaves out.
52 54
53 55
To take over an existing Git project, see section 12.
54 56
…
453 unchanged lines
modified docs/spec.md +1 −1
…
805 unchanged lines
806 806
807 807
| File | Purpose |
808 808
|---|---|
809 -
| `.oxyignore` | Ignore rules. Gitignore syntax and semantics, per directory, plus `.oxy/info/ignore` and the global ignore file. Rules apply to untracked paths only: a tracked path stays tracked, and its edits are seen, until it is deleted or removed from the state. |
809 +
| `.oxyignore` | Ignore rules. Gitignore syntax and semantics, per directory, plus `.oxy/info/ignore` and the global ignore file. A directory's `.gitignore` and `.ignore` files are read too, with lower precedence than its `.oxyignore` (a `!` rule there re-includes what they exclude), so a project coming from Git leaves out what Git did. Rules apply to untracked paths only: a tracked path stays tracked, and its edits are seen, until it is deleted or removed from the state. |
810 810
| `.oxyattributes` | Per-path attributes: `binary` (force opaque), `text` (text unless NUL bytes), `eol=lf|crlf` (store LF; check out as given), `nocompress` (chunks packed raw), `lockable` (read-only until `oxy lock`), `filter=<name>`. Gitattributes syntax: a pattern without `/` matches basenames below the file's directory, one with `/` is anchored to it; later rules and deeper files override. The attributes in force for a change are those in the files being committed. |
811 811
| `.oxy/config` | Repository-local settings, TOML. |
812 812
| `~/.config/oxy/config.toml` | User settings, the same keys as `.oxy/config`: `author.name`, `author.email`, `author.key`, `core.sign`, and `remote.<name>.token`. Overridden by `$OXY_HOME/config.toml` when `$OXY_HOME` is set. |
…
232 unchanged lines
modified src/cli/workspace.rs +38 −0
…
55 unchanged lines
56 56
    }
57 57
}
58 58
59 +
/// Folders holding a repository of their own, which oxy leaves out (oxygen#43):
60 +
/// named, with how to track them.
61 +
fn print_untracked_nested(repo: &Repo, nested: &[String]) {
62 +
    if nested.is_empty() {
63 +
        return;
64 +
    }
65 +
    println!();
66 +
    println!(
67 +
        "{} {}",
68 +
        ui::section(ui::warn("Not tracked")),
69 +
        ui::dim("folders holding a repository of their own")
70 +
    );
71 +
    for p in nested {
72 +
        let kind = if repo.root().join(p).join(".git").exists() {
73 +
            "Git"
74 +
        } else {
75 +
            "Oxygen"
76 +
        };
77 +
        println!(
78 +
            "  {} {}  {}",
79 +
            ui::caution(),
80 +
            ui::path(format!("{p}/")),
81 +
            ui::dim(format!("a nested {kind} repository"))
82 +
        );
83 +
    }
84 +
    println!(
85 +
        "  {} {}",
86 +
        ui::note(),
87 +
        ui::dim(
88 +
            "to track one's files, remove its .git (or .oxy); to keep it a repository of its own, \
89 +
             oxy submodule add URL PATH"
90 +
        )
91 +
    );
92 +
}
93 +
59 94
pub fn status(json: bool) -> Result<i32> {
60 95
    let repo = open()?;
61 96
    // Clean by the manifest alone: no history to load (oxygen#35).
…
14 unchanged lines
76 111
            ui::ok(ui::glyph("✔", "*")),
77 112
            ui::dim("nothing to commit, working copy clean")
78 113
        );
114 +
        print_untracked_nested(&repo, &repo.untracked_nested());
79 115
        return Ok(0);
80 116
    }
81 117
    let sp = ui::spinner("Snapshotting the working copy…");
…
37 unchanged lines
119 155
            );
120 156
        }
121 157
    }
158 +
    print_untracked_nested(&repo, &snap.untracked_nested);
122 159
    if !snap.base_snapshot.is_conflicted() {
123 160
        return Ok(0);
124 161
    }
…
126 unchanged lines
251 288
        ui::hash(format!("#{}", h.short(12))),
252 289
        ui::subject(subject_of(message))
253 290
    ));
291 +
    print_untracked_nested(&repo, &repo.untracked_nested());
254 292
    if !matches!(selection, oxy_core::repo::Selection::All) {
255 293
        let rest = repo.snapshot_status("oxy commit")?;
256 294
        if !rest.statuses.is_empty() {
…
1666 unchanged lines
modified tests/cli_json.rs +29 −0
…
560 unchanged lines
561 561
    let (out, _) = status(&r);
562 562
    assert!(out.contains("c.txt"), "{out}");
563 563
}
564 +
565 +
#[test]
566 +
fn a_nested_git_repository_is_named_not_silently_skipped() {
567 +
    let tmp = tempfile::tempdir().unwrap();
568 +
    let home = tmp.path().join("home");
569 +
    std::fs::create_dir_all(&home).unwrap();
570 +
    let r = tmp.path().join("repo");
571 +
    std::fs::create_dir_all(&r).unwrap();
572 +
    assert_eq!(oxy(&r, &home, &["init", "."]).0, 0);
573 +
    write(&r, "a.txt", "a\n");
574 +
    write(&r, "rust-core/src/lib.rs", "pub fn f() {}\n");
575 +
    write(&r, "rust-core/.git/HEAD", "ref: refs/heads/main\n");
576 +
    let st = json(&r, &home, &["status", "--json"]);
577 +
    assert_eq!(st["untracked_nested"], serde_json::json!(["rust-core"]));
578 +
    let (code, out, err) = oxy(&r, &home, &["commit", "-m", "first"]);
579 +
    assert_eq!(code, 0, "{err}");
580 +
    let out = String::from_utf8_lossy(&out);
581 +
    assert!(
582 +
        out.contains("rust-core/") && out.contains("nested Git repository"),
583 +
        "{out}"
584 +
    );
585 +
    // The clean status says so too.
586 +
    let (_, out, _) = oxy(&r, &home, &["status"]);
587 +
    let out = String::from_utf8_lossy(&out);
588 +
    assert!(
589 +
        out.contains("working copy clean") && out.contains("rust-core/"),
590 +
        "{out}"
591 +
    );
592 +
}