ADR-033: A test case may ask for an isolated Git sandbox¶
Status: accepted
Relates to: ADR-016, ADR-027, ADR-011
Narrows: ADR-027's "before sees nothing but what it creates itself" to a case that has not also set git.
Context¶
[commands]'s sandbox (ADR-027) is a plain scratch directory — never a Git repository. Most tooling a case's
[commands] might run does not care, but anything that reads Git for its own identity does: pdm-backend's
source = "scm" — the generated project's own version source, not the template's — has nothing to read at all
in a directory with no .git, and fails before installing a single dependency. So does every other
setuptools_scm-alike source, and git describe, and git rev-parse --verify HEAD.
A template with several cases that all need pdm install to succeed ends up repeating the same five lines,
verbatim, in every one of their before lists:
[commands]
before = [
"git init -q",
"git config user.email smoke@test",
"git config user.name smoke",
"git config commit.gpgsign false",
"git commit -q --allow-empty -m init",
]
commit.gpgsign false is there because a case cannot assume the machine running git tpl test has no ambient
signing configuration — without it, a real git commit in before can hang waiting on a GPG agent with no TTY to
prompt on, which is worse than a failure: it hangs the run with no error at all.
That line only defuses the one ambient setting most likely to bite. HOME, GIT_CONFIG_GLOBAL and
GIT_CONFIG_SYSTEM are untouched, so a case's result can still depend on whatever else is sitting in whichever
machine's ambient Git configuration happens to be running the suite — a credential helper, a custom hook, an
alias — which sits oddly next to git tpl having a whole "Concepts > Determinism" page. Every case that needs a
repository has to individually rediscover and defend against the next ambient setting that leaks through, the way
commit.gpgsign false already is one case author's own discovery rather than something the tool guarantees.
Decision¶
A case may set git, a sibling of answers/trust/expect/commands/snapshot:
or, overriding the identity:
parsed exactly like snapshot — a plain boolean, or (unlike snapshot) a table overriding two fixed leaves —
defaulting to false/absent for the same reason snapshot does: an isolated repository is an extra assertion a
case opts into, not a side effect of anything else it wrote.
When set, the sandbox is seeded — before before runs its first entry — with one empty commit, authored with the
chosen identity, or a built-in synthetic default (git-tpl test <test@git-tpl.invalid>, .invalid per RFC 2606,
so nobody mistakes it for a real address) when the case wrote git = true rather than overriding it.
Seeding never spawns a process¶
The repository is created and seeded entirely through git-tpl's own Git backend (LibGit2::init_isolated,
set_config_str, build_tree, create_commit, set_ref — all pre-existing, src/git/mod.rs), never a spawned
git. This is what makes the seed commit's identity unconditional rather than merely likely: it is written to the
sandbox's own local .git/config before anything asks for a signature, and Git's own precedence — local always
outranks global and system — makes it authoritative regardless of what either ambient level says, with no ambient
hook, alias or credential helper ever consulted, because none of the machinery that would consult them (a spawned
git) runs at all for this part. commit.gpgsign is set to false in that same local config, so a case's own
[commands] that later run a literal git commit cannot hit the exact hang this ADR's Context describes.
LibGit2::init_isolated also pins the initial branch to main (RepositoryInitOptions::initial_head), rather
than reading whatever init.defaultBranch the running machine's global or system config happens to declare — a
smaller leak than an ambient identity or a signing prompt, but the same class of thing, and one line to close.
A case's own commands are isolated by environment, not by fighting config levels¶
Seeding needs no isolation trick because it never asks Git to search anything. A case's own [commands] are
different: the entire reason to ask for git is so they can run a literal git, and a spawned process does
search the running machine's config exactly as any git invocation would. So every command in a case that set
git — not only the ones that come after seeding — is spawned with GIT_CONFIG_NOSYSTEM=1 and GIT_CONFIG_GLOBAL
pointed at a path inside the sandbox's own .git/ that is never created, so Git treats "global" as empty rather
than reading the running machine's ~/.gitconfig. This mirrors tests/common/mod.rs::scrub_git_env, written for
the identical reason one layer further out: isolating this project's own test suite from the developer's
~/.gitconfig.
The two variables are merged into each list's own resolved env (commands.env, a list's own override) at a
lower precedence than either — the exact override precedent TEMPLATE_ROOT and the colour variables already
have (ADR-027) — so a case that deliberately wants a different value for either still gets it. Riding the same
env: &BTreeMap<String, String> parameter those already pass through execute_commands/run_one/
expand_command means the isolation variables are visible to {{ env.* }} templating for free, and can never
disagree with what the spawned process actually receives, because both come from the one merged map.
HOME is deliberately not touched: GIT_CONFIG_GLOBAL alone overrides the location Git would otherwise compute
from HOME/XDG_CONFIG_HOME, so there is nothing left for HOME to leak through for Git's own purposes, and
changing it for the whole spawned command risks breaking a tool that needs it for an unrelated reason (locating
its own cache, say) that Git's own isolation has no business touching.
--skip-commands/tpl.testCommands disable git too¶
Both existing switches (ADR-027) disable [commands] for a run; they now also skip a case's git, as a
corollary rather than a second toggle. There is nothing for an isolated repository to prove if nothing runs
inside it, and a person who has already said "skip the commands" has said everything relevant about whether the
sandbox needs to be anything more than a scratch directory.
Reuses the existing sandbox_failed diagnostic¶
A seeding failure — LibGit2::init_isolated or a subsequent GitBackend call returning an error — is a fact
about the machine running the suite, not about the template, exactly the class of failure
tpl::testing::sandbox_failed already covers for the sandbox's own tempdir creation. It aborts the run rather
than being recorded as a case failure, and reuses that code rather than minting a new one.
Forward compatibility¶
An old git-tpl reading a case with git already fails loudly today, at the existing deny-unknown-key check in
Case::parse — `git` is not a test case key — the same property commands.env (#130) and lacks (#87)
established for their own additive case-schema changes: never a silent no-op.
Consequences¶
What stays closed. git2 remains confined to src/git/libgit2.rs (ADR-011) — the one new constructor,
LibGit2::init_isolated, is inherent, alongside init/discover/open/clone_bare, per the trait's own
membership rule: it produces a backend rather than uses one. Invariant 5 is untouched: nothing here spawns a
process that was not already ADR-027's own, pre-existing exception, and seeding itself spawns nothing at all.
What changes. src/ops/testing.rs calls into GitBackend to write, for the first time — every existing use
there is a read (workdir, subtree, list_tree, read_blob) against the already-resolved template. The write
lands only in a case's own throwaway sandbox, never touching invariant 1's worktree.
On-disk contract. git is an addition to the case schema ADR-016 already governs, with the same permanence.
Cost. No new dependency. No new GitBackend trait method: set_config_str, set_config_bool, build_tree,
create_commit and set_ref already existed for other callers (init, tests). execute_commands/run_one/
expand_command need no signature change, since the isolation variables ride their existing env parameter.