ref:main
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Test.GitEnv do
@moduledoc """
Keeps the suite's `git` subprocesses inside their own fixtures.
Twenty test files shell out to `git`. Which repository each of those
commands actually operates on is decided by git, not by us, and the working
directory is only one of the inputs — an ambient `GIT_DIR` outranks it
entirely. So isolation cannot be left to each call site remembering to pass
`cd:`; it has to be a property of the harness.
## The two ways a git subprocess escapes its fixture
**An inherited redirect variable.** `git push` runs `.githooks/pre-push`
with `GIT_DIR` (and friends) exported into it, and that hook runs `mix
test`. Every git command in the suite then inherits a `GIT_DIR` pointing at
the repository being pushed. `GIT_DIR` bypasses repository *discovery*
altogether, so `GIT_CEILING_DIRECTORIES` never gets consulted — the ceiling
is a guard on discovery, and discovery no longer happens. The suite proceeds
to run its fixture commits, config writes and ref updates against the
developer's real checkout. Observed damage: `core.bare=true`, `origin`
rewritten to a fixture's HTTP daemon, a stray `lfs.url`, `origin/main` and
branch tips force-updated to fixture commits, ~140 fixture commits on a real
branch (ex_git_objectstore#75, and #78's PR hit it again).
**No working directory at all.** `System.cmd("git", args)` with no `cd:`
runs in the test process's working directory, which is the project root —
where git finds this project's own `.git` immediately. The ceiling cannot
help here either: it stops discovery from *ascending* into a listed
directory, and no ascent is needed when the repository is already at cwd.
## What this module does
`scrub_inherited!/0` removes the redirect variables from the test process
once, at `test_helper.exs`, so nothing can inherit them — that closes the
first route for every git child, including call sites that don't use this
module.
`cmd/3` closes both routes by construction: the directory is a required
argument, and the child's environment has every redirect variable explicitly
cleared, so it cannot be reintroduced by whatever launched the suite.
Not a replacement for `GIT_CEILING_DIRECTORIES` — that still guards
discovery from a scratch directory that turns out not to be a repository,
which is a third and separate route. All three are needed.
"""
# Every variable through which git can be pointed at a repository other than
# the one the working directory implies. `git rev-parse --local-env-vars`
# prints this list; it is inlined so the guard does not depend on being able
# to run git to find out how to run git safely.
@redirect_vars ~w(
GIT_ALTERNATE_OBJECT_DIRECTORIES
GIT_CONFIG
GIT_CONFIG_COUNT
GIT_CONFIG_GLOBAL
GIT_CONFIG_SYSTEM
GIT_COMMON_DIR
GIT_DIR
GIT_INDEX_FILE
GIT_INDEX_VERSION
GIT_INTERNAL_SUPER_PREFIX
GIT_NAMESPACE
GIT_NO_REPLACE_OBJECTS
GIT_OBJECT_DIRECTORY
GIT_PREFIX
GIT_REPLACE_REF_BASE
GIT_SHALLOW_FILE
GIT_WORK_TREE
)
@doc """
The variables treated as repository redirectors.
"""
@spec redirect_vars() :: [String.t()]
def redirect_vars, do: @redirect_vars
@doc """
Drop inherited redirect variables from the current process's environment.
Called once from `test_helper.exs`, before `ExUnit.start/1`, so that no git
subprocess — whether or not it goes through `cmd/3` — can inherit a
redirection from whatever launched the suite.
"""
@spec scrub_inherited!() :: :ok
def scrub_inherited! do
Enum.each(@redirect_vars, &System.delete_env/1)
end
@doc """
An `:env` list that clears every redirect variable for a child process,
with `extra` appended (and therefore taking precedence).
Elixir unsets a variable when its value is `nil`, so this actively removes
the variables from the child rather than relying on them being absent from
the parent.
"""
@spec clean_env([{String.t(), String.t() | nil}]) :: [{String.t(), String.t() | nil}]
def clean_env(extra \\ []) do
Enum.map(@redirect_vars, &{&1, nil}) ++ extra
end
@doc """
Run `git` inside `dir` with a scrubbed environment.
`dir` is required and not defaultable — a git command with no directory is
precisely the failure this module exists to prevent. Returns
`{output, exit_code}` like `System.cmd/3`.
"""
@spec cmd(String.t(), [String.t()], keyword()) :: {Collectable.t(), non_neg_integer()}
def cmd(dir, args, opts \\ []) when is_binary(dir) and is_list(args) do
{extra_env, opts} = Keyword.pop(opts, :env, [])
opts =
opts
|> Keyword.put(:cd, dir)
|> Keyword.put(:env, clean_env(extra_env))
|> Keyword.put_new(:stderr_to_stdout, true)
System.cmd("git", args, opts)
end
@doc """
`cmd/3`, raising on a non-zero exit. Returns trimmed stdout.
"""
@spec cmd!(String.t(), [String.t()], keyword()) :: String.t()
def cmd!(dir, args, opts \\ []) do
case cmd(dir, args, opts) do
{out, 0} ->
String.trim(to_string(out))
{out, code} ->
raise "git #{Enum.join(args, " ")} failed in #{dir} (exit #{code}):\n#{out}"
end
end
end