Summary
An audit of the test suite found that while it is large (~6,550 lines in tests/ plus ~398 inline unit tests) and looks thorough, it has a consistent structural weakness: most integration tests assert on printed output or on the submod.toml text, not on the actual git/filesystem state the tool exists to manipulate. This lets a large fraction of tests pass even when core behavior is broken — and has already let at least one real bug ship undetected (fetchRecurse silently dropped, see P0-3).
This issue tracks the gaps and the recommended fixes. Findings have been spot-verified against the source.
Cross-cutting theme: tests verify narration, not effects
submod's job is to mutate git state (gitlinks, .git/modules, .gitmodules, .git/config, worktrees), but the tests largely check what was printed or what landed in submod.toml:
add (tests/integration_tests.rs:52) checks submod.toml + that a .git exists. Never verifies the index gitlink (mode 160000), the .gitmodules entry, the submodule.<name> config section, or that the submodule HEAD is the right commit. The shallow-clone test (tests/integration_tests.rs:887) is the only add test that shells out to git rev-parse.
delete / nuke assert only !config.contains("[name]") (tests/integration_tests.rs:648, :854; tests/command_contract_tests.rs:244, :1331). Never check the worktree is gone, .git/modules/<name|path> is removed, the index gitlink is cleared, or the .gitmodules entry is gone. The delete code itself notes path-keyed artifacts "may linger and prevent a clean re-add" (src/git_manager.rs:1605).
update / sync are smoke tests (tests/integration_tests.rs:169, :295): they update against a remote that never advanced (guaranteed no-op), asserted via stdout.contains("Updated") || contains("Already up to date").
check/status asserts only printed substrings; is_dirty is a stub that always reports clean when HEAD resolves (src/git_manager.rs:380), and no test can catch that.
P0 — Highest priority
P0-1: Fallback architecture (the core design) is largely unverified
- No failure-injection seam:
gix_ops is a private field with no way to disable gix or force git2/CLI. The only reachable fallbacks are 8 gix methods hard-coded to Err (src/git_ops/gix_ops.rs:317,677,701,707,713,719,725,731) and an incidental 2-part-config-key quirk. For every op gix actually implements (read/write_gitmodules, init/update/delete/deinit, list, fetch), git2's fallback cannot be exercised.
- The CLI last-resort path is dead code from the suite's perspective: the
add_submodule CLI fallback + partial-state cleanup (src/git_ops/mod.rs:316-429) only runs if git2.add_submodule fails, and nothing makes it fail. apply_sparse_checkout_reaches_cli_fallback (tests/fallback_tests.rs:258) asserts nothing on the success path.
- Of ~23 "fallback" tests, only 3 actually force a fallback and assert a correct result; several are mislabeled (e.g.
manager_write_gitmodules_succeeds_despite_gix_limitations exercises a path gix handles fine).
reopen() is never tested for its actual hazard (delete-then-re-add same name in one process); its fatal/non-fatal asymmetry (git2 fatal, gix warning) is untested. Fallback warning logs are never asserted.
Fix: add a failure-injection seam (e.g. GitOpsManager::new taking gix: Option<...>, or a feature-gated failable gix) so git2 fallback and CLI last-resort can be tested for correct results.
P0-2: Destructive ops verified only against TOML, never git cleanup
delete_submodule_by_name (src/git_manager.rs:1581) removes config sections, deletes .git/modules/<path> and .git/modules/<name>, and calls reopen() — none of which any test checks. Note git2's delete_submodule deliberately does NOT touch .gitmodules (src/git_ops/git2_ops.rs:431, "left to higher-level logic"), so a stale .gitmodules entry after delete is a plausible undetectable bug.
Fix: after delete/nuke --kill, assert: worktree gone, .git/modules/<name and path> gone, git ls-files --stage <path> empty, .gitmodules entry gone, git config --get-regexp ^submodule\. empty.
P0-3: Real bug — fetchRecurse / [defaults] branch silently dropped (masked by tests)
The serde field is fetch_recurse (snake_case) with no rename/alias; the struct has no rename_all and no deny_unknown_fields (src/config.rs:71). So fetchRecurse in submod.toml is an unknown key, silently dropped — parsed value is always None. test_config_with_all_git_options "passes" only because it asserts the file text still contains fetchRecurse = "always" (tests/config_tests.rs:164), trivially true since save_config is append-only/verbatim. [defaults] branch is likewise dropped (SubmoduleDefaults has no branch field). Related: SerializableFetchRecurse serializes as always/never via serde but true/false via gitmodules, and from_gitmodules("always") returns Err — no bridge test.
Fix: add serde aliases (fetchRecurse) + deny_unknown_fields; add tests asserting parsed values, not file text; add a cross-system (serde↔gitmodules) round-trip test for FetchRecurse.
P1
update against an advanced remote is untested. Add a test that moves the remote forward, runs update, and asserts the submodule HEAD followed. The git2 update path including the rebase/merge→checkout downgrade (src/git_ops/git2_ops.rs:397) is unverified.
add does not verify real git state (gitlink mode 160000, .gitmodules, submodule.<name> config, HEAD == remote).
check/status stub: is_dirty (src/git_manager.rs:380) always reports clean; no test asserts a dirty submodule is reported dirty. get_submodule_status OIDs/flags are never asserted (tests/git_ops_tests.rs:315).
reopen() / stale-state: add add → delete → add same name+path in one process test.
- CLI-over-file precedence in
Config::load (src/config.rs:1048) is untested; the git2 bridge (to_git2_options / TryFrom ... Git2SubmoduleOptions) and SubmoduleUpdateOptions::from_options recursive-flag logic are untested.
save_config is append-only (src/git_manager.rs:210): edits to existing sections are silently not written back; comment/order "preservation" is only proven against a no-op. Add real load→modify→save→reload tests.
P2
- Security is effectively untested.
tests/security_tests.rs has 2 tests, both the same vector (path component starting with -). Null-byte handling is faked by the harness — run_submod (tests/common/mod.rs:223) intercepts \0 args and returns a fabricated failure without launching the binary. Missing: command/flag injection via submodule URL or name (CVE-2018-17456 class), malicious .gitmodules fed to generate-config, symlink escapes, asserted path-traversal containment.
- Tests that cannot fail meaningfully — delete or fix:
test_invalid_config_file_path (tests/error_handling_tests.rs:67) — zero assertions.
test_sparse_checkout_empty_patterns (tests/sparse_checkout_tests.rs:321) — else { /* failure also acceptable */ }.
test_invalid_sparse_checkout_patterns (tests/error_handling_tests.rs:196) — path-traversal guard whose only assertion is assert!(!stderr.is_empty()) behind failure; passes if traversal is silently accepted.
- Recurring
assert!(!stderr.is_empty()) (tests/error_handling_tests.rs:230,312,334,494) detects "failed somehow," not the cause.
permission_denied / config_file_locked silently no-op as root (zero coverage in CI containers).
- Error-message assertions are wide
|| disjunctions (contains("Failed to <verb>") || contains(name)) that are near-unfailable; no test asserts a specific exit code.
- Idempotency/partial-failure untested: add-same-submodule-twice, delete-nonexistent, and failed-
add-leaves-no-partial-state (orphan lib/... dir, dangling .git/modules, half-written .gitmodules).
merge_from drops other.use_git_default_sparse_checkout (src/config.rs:212) — latent bug, no merge test exposes it.
P3
- Sparse checkout: strongest area (
tests/sparse_checkout_tests.rs:23 checks file contents AND that excluded dirs are absent), but several tests (:74, :231, :358) only check wanted paths exist, not that excluded paths are absent — so they don't prove sparseness. All lean on get_sparse_checkout_file_path (tests/common/mod.rs:288), which falls back to locations[0] even when nothing exists (:357), masking "file not where expected."
- Performance tests aren't benchmarks despite the CLAUDE.md criterion claim —
Instant + loose ceilings (30s/120s) that catch only hangs; test_memory_usage… measures no memory; "concurrent" tests run sequentially.
- Concurrency is the stated reason tests are serialized (
.config/nextest.toml, .git/index.lock contention), yet no test exercises concurrent ops or a held lock.
- Zero-coverage public surface in
src/config.rs: SubmoduleUpdateOptions::* (:298), from_options_and_settings (:495), update_with_settings (:577), set_sparse_paths_for (:897), loaders load_from_file/load_with_git_sync/sync_with_git_config.
Suggested first PRs (concrete, high-signal)
- P0-2 — git-state assertions for
delete/nuke/add.
- P0-3 — fix
fetchRecurse/defaults.branch silent-drop + parsed-value tests.
- P0-1 — failure-injection seam to actually test git2/CLI fallback.
- P1 —
update against an advanced remote.
Audit performed via multi-agent review of tests/ and src/; claims spot-verified against source.
Summary
An audit of the test suite found that while it is large (~6,550 lines in
tests/plus ~398 inline unit tests) and looks thorough, it has a consistent structural weakness: most integration tests assert on printed output or on thesubmod.tomltext, not on the actual git/filesystem state the tool exists to manipulate. This lets a large fraction of tests pass even when core behavior is broken — and has already let at least one real bug ship undetected (fetchRecursesilently dropped, see P0-3).This issue tracks the gaps and the recommended fixes. Findings have been spot-verified against the source.
Cross-cutting theme: tests verify narration, not effects
submod's job is to mutate git state (gitlinks,.git/modules,.gitmodules,.git/config, worktrees), but the tests largely check what was printed or what landed insubmod.toml:add(tests/integration_tests.rs:52) checkssubmod.toml+ that a.gitexists. Never verifies the index gitlink (mode160000), the.gitmodulesentry, thesubmodule.<name>config section, or that the submodule HEAD is the right commit. The shallow-clone test (tests/integration_tests.rs:887) is the only add test that shells out togit rev-parse.delete/nukeassert only!config.contains("[name]")(tests/integration_tests.rs:648,:854;tests/command_contract_tests.rs:244,:1331). Never check the worktree is gone,.git/modules/<name|path>is removed, the index gitlink is cleared, or the.gitmodulesentry is gone. The delete code itself notes path-keyed artifacts "may linger and prevent a clean re-add" (src/git_manager.rs:1605).update/syncare smoke tests (tests/integration_tests.rs:169,:295): they update against a remote that never advanced (guaranteed no-op), asserted viastdout.contains("Updated") || contains("Already up to date").check/status asserts only printed substrings;is_dirtyis a stub that always reports clean when HEAD resolves (src/git_manager.rs:380), and no test can catch that.P0 — Highest priority
P0-1: Fallback architecture (the core design) is largely unverified
gix_opsis a private field with no way to disable gix or force git2/CLI. The only reachable fallbacks are 8 gix methods hard-coded toErr(src/git_ops/gix_ops.rs:317,677,701,707,713,719,725,731) and an incidental 2-part-config-key quirk. For every op gix actually implements (read/write_gitmodules, init/update/delete/deinit, list, fetch), git2's fallback cannot be exercised.add_submoduleCLI fallback + partial-state cleanup (src/git_ops/mod.rs:316-429) only runs ifgit2.add_submodulefails, and nothing makes it fail.apply_sparse_checkout_reaches_cli_fallback(tests/fallback_tests.rs:258) asserts nothing on the success path.manager_write_gitmodules_succeeds_despite_gix_limitationsexercises a path gix handles fine).reopen()is never tested for its actual hazard (delete-then-re-add same name in one process); its fatal/non-fatal asymmetry (git2 fatal, gix warning) is untested. Fallback warning logs are never asserted.Fix: add a failure-injection seam (e.g.
GitOpsManager::newtakinggix: Option<...>, or a feature-gated failable gix) so git2 fallback and CLI last-resort can be tested for correct results.P0-2: Destructive ops verified only against TOML, never git cleanup
delete_submodule_by_name(src/git_manager.rs:1581) removes config sections, deletes.git/modules/<path>and.git/modules/<name>, and callsreopen()— none of which any test checks. Note git2'sdelete_submoduledeliberately does NOT touch.gitmodules(src/git_ops/git2_ops.rs:431, "left to higher-level logic"), so a stale.gitmodulesentry after delete is a plausible undetectable bug.Fix: after
delete/nuke --kill, assert: worktree gone,.git/modules/<name and path>gone,git ls-files --stage <path>empty,.gitmodulesentry gone,git config --get-regexp ^submodule\.empty.P0-3: Real bug —
fetchRecurse/[defaults] branchsilently dropped (masked by tests)The serde field is
fetch_recurse(snake_case) with norename/alias; the struct has norename_alland nodeny_unknown_fields(src/config.rs:71). SofetchRecurseinsubmod.tomlis an unknown key, silently dropped — parsed value is alwaysNone.test_config_with_all_git_options"passes" only because it asserts the file text still containsfetchRecurse = "always"(tests/config_tests.rs:164), trivially true sincesave_configis append-only/verbatim.[defaults] branchis likewise dropped (SubmoduleDefaultshas nobranchfield). Related:SerializableFetchRecurseserializes asalways/nevervia serde buttrue/falsevia gitmodules, andfrom_gitmodules("always")returnsErr— no bridge test.Fix: add serde aliases (
fetchRecurse) +deny_unknown_fields; add tests asserting parsed values, not file text; add a cross-system (serde↔gitmodules) round-trip test forFetchRecurse.P1
updateagainst an advanced remote is untested. Add a test that moves the remote forward, runsupdate, and asserts the submodule HEAD followed. The git2 update path including the rebase/merge→checkout downgrade (src/git_ops/git2_ops.rs:397) is unverified.adddoes not verify real git state (gitlink mode160000,.gitmodules,submodule.<name>config, HEAD == remote).check/status stub:is_dirty(src/git_manager.rs:380) always reports clean; no test asserts a dirty submodule is reported dirty.get_submodule_statusOIDs/flags are never asserted (tests/git_ops_tests.rs:315).reopen()/ stale-state: addadd → delete → add same name+path in one processtest.Config::load(src/config.rs:1048) is untested; the git2 bridge (to_git2_options/TryFrom ... Git2SubmoduleOptions) andSubmoduleUpdateOptions::from_optionsrecursive-flag logic are untested.save_configis append-only (src/git_manager.rs:210): edits to existing sections are silently not written back; comment/order "preservation" is only proven against a no-op. Add real load→modify→save→reload tests.P2
tests/security_tests.rshas 2 tests, both the same vector (path component starting with-). Null-byte handling is faked by the harness —run_submod(tests/common/mod.rs:223) intercepts\0args and returns a fabricated failure without launching the binary. Missing: command/flag injection via submodule URL or name (CVE-2018-17456 class), malicious.gitmodulesfed togenerate-config, symlink escapes, asserted path-traversal containment.test_invalid_config_file_path(tests/error_handling_tests.rs:67) — zero assertions.test_sparse_checkout_empty_patterns(tests/sparse_checkout_tests.rs:321) —else { /* failure also acceptable */ }.test_invalid_sparse_checkout_patterns(tests/error_handling_tests.rs:196) — path-traversal guard whose only assertion isassert!(!stderr.is_empty())behind failure; passes if traversal is silently accepted.assert!(!stderr.is_empty())(tests/error_handling_tests.rs:230,312,334,494) detects "failed somehow," not the cause.permission_denied/config_file_lockedsilently no-op as root (zero coverage in CI containers).||disjunctions (contains("Failed to <verb>") || contains(name)) that are near-unfailable; no test asserts a specific exit code.add-leaves-no-partial-state (orphanlib/...dir, dangling.git/modules, half-written.gitmodules).merge_fromdropsother.use_git_default_sparse_checkout(src/config.rs:212) — latent bug, no merge test exposes it.P3
tests/sparse_checkout_tests.rs:23checks file contents AND that excluded dirs are absent), but several tests (:74,:231,:358) only check wanted paths exist, not that excluded paths are absent — so they don't prove sparseness. All lean onget_sparse_checkout_file_path(tests/common/mod.rs:288), which falls back tolocations[0]even when nothing exists (:357), masking "file not where expected."Instant+ loose ceilings (30s/120s) that catch only hangs;test_memory_usage…measures no memory; "concurrent" tests run sequentially..config/nextest.toml,.git/index.lockcontention), yet no test exercises concurrent ops or a held lock.src/config.rs:SubmoduleUpdateOptions::*(:298),from_options_and_settings(:495),update_with_settings(:577),set_sparse_paths_for(:897), loadersload_from_file/load_with_git_sync/sync_with_git_config.Suggested first PRs (concrete, high-signal)
delete/nuke/add.fetchRecurse/defaults.branchsilent-drop + parsed-value tests.updateagainst an advanced remote.Audit performed via multi-agent review of
tests/andsrc/; claims spot-verified against source.