ref:a7e0745724e439f7d28232e1afcf80e06a4cb800

fix(test): stop the suite escaping its fixtures; open a pin-bump PR on merge to main (#51)

Two changes to this repository's CI and test infrastructure. They are unrelated in mechanism but both are about CI doing what it appears to do. - **Closes #75** — the test suite could reach out of its fixtures and corrupt the developer's checkout, via an inherited `GIT_DIR`. `git push` in this repo is safe again. - **Refs #81** — a merge to `main` now opens a PR on `fangorn/anvil` bumping its pinned `ex_git_objectstore` reference, so a fix here stops sitting unconsumed. --- # Part 1 — the suite escaping its fixtures (#75) ## The bug `.githooks/pre-push` runs `mix test`, and git exports `GIT_DIR`, `GIT_WORK_TREE` and friends into hook processes. So **every `git push` ran the whole suite aimed at the repository being pushed.** Twenty test files shell out to git; their fixture commits, config writes and ref updates landed on the developer's real checkout. This has now hit three people. Most recently it fired during PR #50's push and left that checkout with `core.bare=true`, `remote.origin.url` pointed at a fixture's HTTP daemon, a stray `lfs.url`, `origin/main` force-updated to a fixture commit, **the branch being pushed force-updated to a fixture commit named `pushed`**, and both indexes rewritten. `GIT_CEILING_DIRECTORIES` does not cover this and structurally cannot: it guards repository *discovery*, and a redirect variable means discovery never happens. ## Reproduced Against a throwaway repository rather than a real checkout, so the reproduction is evidence and not another incident: ``` $ GIT_DIR=<throwaway>/.git GIT_WORK_TREE=<throwaway> mix test Result: 930/1043 passed $ # the throwaway afterwards lfs.url http://127.0.0.1:61357/interop_multi_48902/info/lfs (was unset) refs/heads/main 6f49a38 (was ad33e0c) commits 3 (was 1) ``` The existing isolation test also fails under that environment, which is the cleanest statement of why the ceiling is not enough: ``` 5) test git cannot escape a scratch dir upward into the project repo git escaped the scratch dir and mutated a real repo: ``` ## Why a scrub alone was not the fix #75 records that scrubbing `GIT_DIR` in `test_helper.exs` made the suite green but **moved** the corruption. I measured where it moves to, and it is a second, independent route: `System.cmd("git", args)` with no `cd:` runs in the project root, where git finds this project's `.git` immediately and the ceiling is powerless because nothing ascends. ``` $ cd <project> && GIT_CEILING_DIRECTORIES=<project> git config --local test.injected yes $ git config --get test.injected yes ``` Seven call sites had that shape. For completeness I also tested the route the issue *speculated* about — discovery from below, out of a fixture dir under the project root. With the ceiling set it is already blocked, so it is not part of this fix. ## The fix **`test/support/git_env.ex`** owns the list of variables through which git can be redirected, and neutralises them two ways: - `scrub_inherited!/0` — drops them from the test process, called from `test_helper.exs` before `ExUnit.start/1`, so nothing inherits a redirection regardless of whether a call site uses the module. - `cmd/3` — the directory is a **required argument** and every redirect variable is explicitly cleared for the child, so an ambient value cannot be reintroduced. The seven undirected call sites now name their directory. `GitDaemon.git_at/3` gains the scrubbed environment; its `dir` stays optional because `git clone <url> <dest>` names its target explicitly. **The hook is untouched.** It was right to run the suite; the suite was wrong to be unsafe to run. ## Result | | before | after | |---|---|---| | `GIT_DIR=<throwaway> mix test` | 930/1043 | **1048 passed, 0 failures** | | throwaway config / refs / commits | `lfs.url` written, `main` advanced 1 → 3 commits | **unchanged** | And the proof that matters — a real `git push` with the hook enabled, the operation behind every incident: ``` $ git push -u origin fix/75-test-git-env-isolation Result: 1048 passed (2 properties, 1046 tests), 52 excluded * [new branch] fix/75-test-git-env-isolation -> fix/75-test-git-env-isolation ``` The checkout afterwards is byte-identical to a snapshot taken before it, except for the refs the push is supposed to create. **`git push` in this repo is safe again** and `--no-verify` is no longer required. ## Regression coverage `TestIsolationTest` states the invariant as three routes, each guarded, using a **throwaway repository as the victim** so a regression fails an assertion instead of corrupting a checkout. Under a poisoned `GIT_DIR` before the fix, **5 of 7 fail** — including `git init` itself and the pre-existing ceiling test. The scan test ("no test file shells out to git without pinning it to a directory") is what stops the shape coming back. I verified it is not vacuous by injecting an offending call into an unrelated file: reported at `delta_test.exs:80`, green again on removal. Two bugs I introduced and then found, both of which would have shipped as flakes: - `TestIsolationTest` had to become `async: false`. It sets `GIT_DIR` process-globally to reproduce the hook, and every git subprocess inherits it; run concurrently the window before cleanup leaked into whichever async test happened to shell out. It surfaced as a *neighbouring* test failing with `GIT_WORK_TREE not allowed without specifying GIT_DIR`. - The scanner flagged **itself** once `mix format` inserted a blank line that pushed its own `cd:` check outside a 5-line window. It now builds its needle at runtime and uses a wider one. --- # Part 2 — bumping Anvil's pin on merge to main (#81) ## The gap Anvil consumes this library as a git dependency **pinned by SHA**. Nothing moved that pin, so a fix can land on `main` and sit unconsumed indefinitely — which is the situation right now: #78's receive-pack fix and #75 above are both invisible to Anvil until someone remembers `mix deps.update ex_git_objectstore` by hand. A step gated on merges to `main` now opens a PR on `fangorn/anvil` moving the pin to that commit, so the change goes through Anvil's review and full test suite. **The PR is opened, never merged** — Anvil's CI is the gate and a human merges. ## The pin lives in two places Worth stating because the issue describes it as "`mix.lock`'s entry and nothing else", which would not have worked: | file | what it carries | |---|---| | `mix.exs` | `ref: "<sha>"` on the dependency — **authoritative** | | `mix.lock` | the resolved SHA, twice | Mix treats a lock that disagrees with `mix.exs` as stale and re-resolves from the dependency, so editing only the lock does nothing at all. Both move together, and a file that does not contain the reference in the expected shape is an error rather than something to write over. ## Gating Compares the checked-out SHA against `origin/main`, following the existing `release` step. CI checks out a detached HEAD so the branch name is always `HEAD`, and `branch contains 'main'` is a substring match that has already matched `feat/324-git-maintenance` (fangorn/anvil#234). ## Idempotency One fixed branch, force-pushed; repeated merges update the single PR opened from it. The listing is read with an explicit `--limit` well past the paginated default of **30** — an existing bump PR past the first page would read as "none open" and get a duplicate created, which is precisely the pile-up this is meant to prevent. ## The credential is a prerequisite > **This step will fail until an admin provisions a secret.** That is deliberate. The runner injects `ANVIL_TOKEN` scoped to the dispatching repository; it cannot write to `fangorn/anvil` (fangorn/anvil#390). The step reads a separately provisioned: ``` name: ANVIL_PIN_BUMP_TOKEN value: an Anvil token that can push a branch to fangorn/anvil and open a pull request on it (contents: write) ``` **Deliberately not `ANVIL_TOKEN`** — CI secrets are merged *over* the job environment (`runner_executor_controller.ex`), so a secret by that name would silently replace the injected per-job token for every other step in the job. Absent, the step fails and prints exactly that. A silent skip would leave Anvil pinned to an old commit with nothing to show anything was missed, which is the bug being fixed. ## What is tested, and what isn't Everything decidable is a pure function in `ci/pin_bump.exs`, tested directly rather than through a subprocess: rewriting each pinned reference, deciding update-vs-create, reading the credential, decoding the CLI listing, rendering the body. I/O is confined to `main/1` and decides nothing. **Not unit-tested, and I would rather say so than write a test that asserts nothing:** the shell in `.anvil.yml` (the gate, the CLI download) and `main/1`'s orchestration. Testing those meaningfully needs a real runner, a real cross-repo credential, and a real Anvil to open a PR against — the first genuine exercise is the first merge to `main` after the secret exists. What I did instead of pretending otherwise: - ran the rewriters against **Anvil's actual `mix.exs` and `mix.lock`**, not just fixtures — exactly one line changes in each, and reversing the SHA reproduces both originals byte for byte; - ran `decode_prs/1` against the **live CLI payload** — 17 open PRs, correctly parsed out of the `pull_requests` envelope; - ran the missing-credential path end to end and confirmed it exits non-zero with the actionable message; - validated the YAML parses and the step's `depends_on` is the full green set. `ci/` is outside `elixirc_paths`, so none of this ships in the published package. `.formatter.exs` now covers it so the `format` step still checks it. ## Two bugs the tests caught before this could write anything - `Regex.replace` with `\1` followed by a SHA beginning with a digit parses as group **15**, silently eating the first character and writing a corrupt `mix.lock`. Uses `\g{1}`. - The CLI returns an object with `pull_requests`, not a bare array. The first version read every listing as empty and would have opened a duplicate PR on every single merge — defeating the one requirement that matters most here. --- ## Requirements - **REQ-GIT-084** — the suite cannot reach any repository but its own fixtures, in any ambient git environment. - **REQ-CI-001** — a merge to main opens or updates a *single* PR on the consumer bumping its pinned reference. - **REQ-CI-002** — the bump moves every place the reference is pinned, and nothing else. - **REQ-CI-003** — the cross-repo credential is required explicitly and its absence fails with an actionable message. All created before the tests referencing them; every new test carries `@tag requirements: [...]`. ## Gates | | | |---|---| | `mix test` | **1071 passed**, 0 failures (52 excluded `:s3`) | | `mix test` under a poisoned `GIT_DIR` | 1048 passed, 0 failures | | `mix test` under the real pre-push hook | 1048 passed, 0 failures | | `mix dialyzer` | Total errors: 0 | | `mix credo --strict` | unchanged from this branch's baseline — adds none | | `mix compile --warnings-as-errors` | clean | | `mix format --check-formatted` | clean, now including `ci/` |
SHA: a7e0745724e439f7d28232e1afcf80e06a4cb800
Author: Anvil <noreply@anvil.fangorn.io>
Date: 2026-07-31 04:18
Parents: 850623a
12 files changed +1192 -12
Type
.anvil.yml +38 −0
@@ -153,3 +153,41 @@
echo "Released $VERSION"
depends_on: [compile, format, dialyzer, test]
# On a merge to main, open a PR on fangorn/anvil moving its pinned
# ex_git_objectstore reference to this commit (#81). Anvil pins us by SHA,
# and nothing moved that pin — so a fix could land here and sit unconsumed
# indefinitely, which is what happened to #78 and #75.
#
# The PR is opened, never merged: Anvil's CI is the gate and a human merges.
- name: bump-anvil-pin
run: |
set -e
git config --global --add safe.directory /workspace
export MIX_HOME=/workspace/.mix
# Same gate as `release` above: CI checks out a detached HEAD, so the
# branch name is always "HEAD". Compare the checked-out SHA against
# origin/main instead. Deliberately not a `branch contains 'main'`
# match — that is a substring test and it matched
# feat/324-git-maintenance (fangorn/anvil#234).
git fetch origin main 2>/dev/null || true
HEAD_SHA=$(git rev-parse HEAD)
MAIN_SHA=$(git rev-parse origin/main 2>/dev/null || echo "")
if [ "$HEAD_SHA" != "$MAIN_SHA" ]; then
echo "Not a merge to main (HEAD $HEAD_SHA != origin/main $MAIN_SHA) — skipping pin bump"
exit 0
fi
# The runner-injected ANVIL_TOKEN is scoped to THIS repository and
# cannot write to fangorn/anvil (fangorn/anvil#390), so the bump needs a
# separately provisioned cross-repo credential. Its absence fails the
# job loudly — a silent skip would leave Anvil pinned to an old commit
# with nothing to show anything was missed. The script prints exactly
# what an admin has to provision.
curl -sL "https://anvil.fangorn.io/runner/download?os=$(uname -s)&arch=$(uname -m)" -o /usr/local/bin/anvil
chmod +x /usr/local/bin/anvil
PIN_BUMP_SHA="$HEAD_SHA" mix run --no-start -r ci/pin_bump.exs \
-e 'ExGitObjectstore.CI.PinBump.main()'
depends_on: [compile, format, dialyzer, test]
.formatter.exs +4 −1
@@ -1,4 +1,7 @@
# Used by "mix format"
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
# `ci/` carries real Elixir (ci/pin_bump.exs) that is deliberately outside
# elixirc_paths so it never ships in the package — but it should still be
# format-checked by the `format` CI step like everything else.
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}", "ci/**/*.{ex,exs}"]
]
ci/pin_bump.exs +471 −0
@@ -1,0 +1,471 @@
# 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.CI.PinBump do
@moduledoc """
Opens a pull request on `fangorn/anvil` moving its pinned
`ex_git_objectstore` reference to a new commit (#81).
Anvil consumes this library as a git dependency pinned by SHA. Nothing moved
that pin, so a fix could land on `main` and sit unconsumed indefinitely —
which is what happened to #78 and #75.
This is CI tooling, not library code, so it lives in `ci/` and is not in
`elixirc_paths`: it never ships in the published package. It is loaded
explicitly, by the CI step that runs it and by its test.
## Everything decidable is a pure function
The I/O — cloning Anvil, running git, calling the `anvil` CLI — is confined
to `main/1` and the handful of functions it calls. The parts with actual
logic take strings and return strings, so they are tested directly rather
than through a subprocess: rewriting the two pinned references, deciding
whether an open PR already exists, reading the credential, and rendering the
body.
## The pin lives in two places
`mix.exs` carries `ref: "<sha>"` on the dependency and `mix.lock` carries the
resolved SHA. **`mix.exs` is authoritative** — Mix treats a lock that
disagrees with it as stale and re-resolves from the dependency — so a bump
that edits only the lock does nothing at all. Both are rewritten, and a file
that does not contain the reference in the shape expected is an error rather
than something to write over.
"""
@anvil_repo "fangorn/anvil"
@dep_name "ex_git_objectstore"
# One fixed branch, reused. This is what makes the whole thing idempotent:
# repeated merges force-push the same branch and update the one PR opened
# from it, instead of accumulating a pile of them.
@branch "chore/bump-ex-git-objectstore"
# Deliberately not `ANVIL_TOKEN`. CI secrets are merged *over* the job
# environment, so a secret by that name would silently replace the injected
# per-job token for every other step in the job.
@token_var "ANVIL_PIN_BUMP_TOKEN"
@sha_pattern "[0-9a-f]{40}"
def branch, do: @branch
def token_var, do: @token_var
def anvil_repo, do: @anvil_repo
# ── Pinned reference rewriting ──────────────────────────────────────────
@doc """
The SHA `mix.exs` currently pins, or an error if the dependency is not
declared in the shape this understands.
"""
@spec current_pin(String.t()) :: {:ok, String.t()} | {:error, String.t()}
def current_pin(mix_exs) do
case Regex.run(~r/ref:\s*"(#{@sha_pattern})"/, mix_exs, capture: :all_but_first) do
[sha] -> {:ok, sha}
_ -> {:error, "mix.exs has no `ref: \"<40-hex>\"` for #{@dep_name}"}
end
end
@doc """
Rewrites the `ref:` in `mix.exs`.
Exactly one occurrence must match. Zero means the dependency is not declared
as expected; more than one means the file changed shape and a blind rewrite
could move an unrelated pin.
"""
@spec bump_mix_exs(String.t(), String.t()) :: {:ok, String.t()} | {:error, String.t()}
def bump_mix_exs(mix_exs, new_sha) do
with :ok <- validate_sha(new_sha) do
pattern = ~r/ref:\s*"#{@sha_pattern}"/
case Regex.scan(pattern, mix_exs) do
[_one] -> {:ok, Regex.replace(pattern, mix_exs, ~s(ref: "#{new_sha}"))}
[] -> {:error, "mix.exs has no `ref: \"<40-hex>\"` to bump"}
many -> {:error, "mix.exs has #{length(many)} `ref:` pins; refusing to guess"}
end
end
end
@doc """
Rewrites the `#{@dep_name}` line in `mix.lock`.
The entry carries the SHA twice — once as the resolved commit and once in
the `ref:` options — and both move. Only that line is touched.
"""
@spec bump_mix_lock(String.t(), String.t()) :: {:ok, String.t()} | {:error, String.t()}
def bump_mix_lock(mix_lock, new_sha) do
with :ok <- validate_sha(new_sha) do
pattern =
~r/^(\s*"#{@dep_name}":\s*\{:git,\s*"[^"]+",\s*")#{@sha_pattern}(",\s*\[ref:\s*")#{@sha_pattern}("\]\},?)$/m
case Regex.scan(pattern, mix_lock) do
[_one] ->
# `\g{1}` rather than `\1`: a SHA beginning with a digit would other-
# wise be read as part of the group number — `\1` followed by "5..."
# parses as group 15, silently eating the first character of the SHA
# and writing a corrupt lock.
replacement = "\\g{1}#{new_sha}\\g{2}#{new_sha}\\g{3}"
{:ok, Regex.replace(pattern, mix_lock, replacement)}
[] ->
{:error, "mix.lock has no git entry for #{@dep_name} in the expected shape"}
many ->
{:error, "mix.lock has #{length(many)} entries for #{@dep_name}; refusing to guess"}
end
end
end
defp validate_sha(sha) do
if Regex.match?(~r/^#{@sha_pattern}$/, sha || "") do
:ok
else
{:error, "#{inspect(sha)} is not a 40-character hex SHA"}
end
end
# ── Idempotency ─────────────────────────────────────────────────────────
@doc """
Whether to update an existing pin-bump PR or open a new one.
Takes the PRs the CLI reports as open, as `%{"number" => n, "head_branch" =>
b}` maps. Only a PR opened from our own branch counts — an unrelated open PR
must never be rewritten. If several match, the lowest number wins so the
choice is stable across runs rather than depending on listing order.
"""
@spec pr_action([map()]) :: {:update, integer()} | :create
def pr_action(open_prs) do
open_prs
|> Enum.filter(&(Map.get(&1, "head_branch") == @branch))
|> Enum.map(&Map.get(&1, "number"))
|> Enum.reject(&is_nil/1)
|> Enum.sort()
|> case do
[number | _] -> {:update, number}
[] -> :create
end
end
# ── Credential ──────────────────────────────────────────────────────────
@doc """
Reads the cross-repo credential, or explains precisely what is missing.
The runner's injected `ANVIL_TOKEN` is scoped to the dispatching repository
and cannot write to Anvil (fangorn/anvil#390), so this needs a separately
provisioned secret. Its absence is a hard failure — skipping silently would
leave the pin quietly unbumped, which is the bug this exists to fix.
"""
@spec fetch_token(map()) :: {:ok, String.t()} | {:error, String.t()}
def fetch_token(env) do
case env |> Map.get(@token_var) |> to_string() |> String.trim() do
"" -> {:error, missing_token_message()}
token -> {:ok, token}
end
end
@doc false
def missing_token_message do
"""
#{@token_var} is not set, so the #{@dep_name} pin on #{@anvil_repo} cannot be bumped.
The runner injects ANVIL_TOKEN scoped to this repository only; it cannot
write to #{@anvil_repo}. This step needs a separate credential, provisioned
once by an admin as a CI secret on this repository:
name: #{@token_var}
value: an Anvil token that can push a branch to #{@anvil_repo}
and open a pull request on it (contents: write)
Until that secret exists this step will keep failing, which is deliberate:
a silent skip would leave Anvil pinned to an old commit with nothing to
show that anything was missed.
"""
end
# ── PR content ──────────────────────────────────────────────────────────
@doc """
The PR body: what moved, and which commits it brings in.
A reviewer approving a dependency bump needs the commits, not a pair of
SHAs — the whole point of routing this through review is that someone can
see what they are taking.
"""
@spec pr_body(String.t(), String.t(), [String.t()]) :: String.t()
def pr_body(old_sha, new_sha, commit_lines) do
commits =
case commit_lines do
[] -> "_(no commits listed — the range was empty or could not be read)_"
lines -> Enum.map_join(lines, "\n", &"- #{&1}")
end
"""
Moves Anvil's pinned `#{@dep_name}` reference to the current `main`.
| | |
|---|---|
| from | `#{old_sha}` |
| to | `#{new_sha}` |
## Commits being pulled in
#{commits}
---
Opened automatically when a commit landed on `#{@dep_name}` `main`
(ex_git_objectstore#81). Both places the reference is pinned move together:
the `ref:` on the dependency in `mix.exs`, which is authoritative, and the
resolved SHA in `mix.lock`.
This PR is opened, never merged — Anvil's CI is the gate and a human
merges. Repeated merges to `#{@dep_name}` `main` update this same PR rather
than opening more.
"""
end
@doc """
The PR title, carrying the short SHA so the branch's history is legible.
"""
@spec pr_title(String.t()) :: String.t()
def pr_title(new_sha), do: "chore(deps): bump #{@dep_name} to #{String.slice(new_sha, 0, 8)}"
# ── The I/O edge ────────────────────────────────────────────────────────
#
# Everything above is pure and tested directly. What follows is the part
# that talks to git, the filesystem and the `anvil` CLI, so it is kept as
# thin as possible: it decides nothing that is not decided above.
@doc """
Clones Anvil, rewrites both pinned references, and opens or updates the PR.
Reads its inputs from the environment so the CI step passes nothing
positionally:
* `#{@token_var}` — the cross-repo credential (required)
* `ANVIL_SERVER_URL` — injected by the runner
* `PIN_BUMP_SHA` — the commit to pin (defaults to `HEAD`)
* `PIN_BUMP_DRY_RUN` — when set, does everything except push and call the
CLI, so the rewrite can be exercised without a credential
"""
def main(env \\ System.get_env()) do
with {:ok, token} <- fetch_token(env),
{:ok, new_sha} <- resolve_sha(env),
{:ok, workdir} <- clone_anvil(env, token),
{:ok, old_sha} <- rewrite_pins(workdir, new_sha) do
commits = commit_lines(old_sha, new_sha)
publish(env, workdir, token, old_sha, new_sha, commits)
else
{:error, message} -> abort(message)
end
end
defp abort(message) do
IO.puts(:stderr, "\n" <> message)
System.halt(1)
end
defp resolve_sha(env) do
case env |> Map.get("PIN_BUMP_SHA") |> to_string() |> String.trim() do
"" -> git(["rev-parse", "HEAD"], ".")
sha -> {:ok, sha}
end
end
defp clone_anvil(env, token) do
server =
env
|> Map.get("ANVIL_SERVER_URL", "https://anvil.fangorn.io")
|> String.replace_prefix("https://", "")
|> String.replace_prefix("http://", "")
|> String.trim_trailing("/")
workdir = Path.join(System.tmp_dir!(), "pin-bump-#{System.unique_integer([:positive])}")
url = "https://x-token:#{token}@#{server}/#{@anvil_repo}.git"
case git(["clone", "--depth", "50", url, workdir], ".") do
{:ok, _} -> {:ok, workdir}
{:error, reason} -> {:error, "could not clone #{@anvil_repo}: #{reason}"}
end
end
defp rewrite_pins(workdir, new_sha) do
exs_path = Path.join(workdir, "mix.exs")
lock_path = Path.join(workdir, "mix.lock")
with {:ok, exs} <- read(exs_path),
{:ok, lock} <- read(lock_path),
{:ok, old_sha} <- current_pin(exs),
{:ok, new_exs} <- bump_mix_exs(exs, new_sha),
{:ok, new_lock} <- bump_mix_lock(lock, new_sha) do
File.write!(exs_path, new_exs)
File.write!(lock_path, new_lock)
{:ok, old_sha}
end
end
defp read(path) do
case File.read(path) do
{:ok, contents} -> {:ok, contents}
{:error, reason} -> {:error, "could not read #{path}: #{inspect(reason)}"}
end
end
# Best-effort: the log is for the reviewer's benefit, so a range that cannot
# be read produces an empty list rather than failing the bump.
defp commit_lines(old_sha, new_sha) do
case git(["log", "--oneline", "--no-decorate", "#{old_sha}..#{new_sha}"], ".") do
{:ok, ""} -> []
{:ok, out} -> String.split(out, "\n", trim: true)
{:error, _} -> []
end
end
defp publish(env, workdir, token, old_sha, new_sha, commits) do
title = pr_title(new_sha)
body = pr_body(old_sha, new_sha, commits)
if Map.get(env, "PIN_BUMP_DRY_RUN") in [nil, ""] do
do_publish(workdir, token, title, body)
else
IO.puts("PIN_BUMP_DRY_RUN set — not pushing. Would open:\n\n#{title}\n\n#{body}")
:ok
end
end
defp do_publish(workdir, token, title, body) do
with {:ok, _} <- git(["checkout", "-B", @branch], workdir),
{:ok, _} <- git(["add", "mix.exs", "mix.lock"], workdir),
{:ok, _} <- commit(workdir, title),
# `--no-thin`: Anvil's receive-pack cannot resolve thin-pack
# REF_DELTAs against packed objects (ex_git_objectstore#78).
{:ok, _} <- git(["push", "--force", "--no-thin", "origin", @branch], workdir),
{:ok, _} <- open_or_update(token, title, body) do
IO.puts("Pin bump published on #{@branch}.")
:ok
else
{:error, reason} -> abort("failed to publish the pin bump: #{reason}")
end
end
defp commit(workdir, title) do
_ = git(["config", "user.email", "ci@anvil.fangorn.io"], workdir)
_ = git(["config", "user.name", "Anvil CI"], workdir)
case git(["diff", "--cached", "--quiet"], workdir) do
# Nothing staged: the pin already points at this SHA.
{:ok, _} -> {:error, "the pin is already at this SHA; nothing to do"}
{:error, _} -> git(["commit", "-m", title], workdir)
end
end
defp open_or_update(token, title, body) do
case pr_action(list_open_prs(token)) do
{:update, number} ->
anvil_cli(token, [
"pr",
"edit",
to_string(number),
"--repo",
@anvil_repo,
"--title",
title,
"--body",
body
])
:create ->
anvil_cli(token, [
"pr",
"create",
"--repo",
@anvil_repo,
"--base",
"main",
"--head",
@branch,
"--title",
title,
"--body",
body
])
end
end
# `--limit` is raised well past the default 30: the listing is paginated,
# and an existing pin-bump PR sitting past the first page would read as "no
# PR open" and get a second one created — which is exactly the pile-up this
# is supposed to prevent.
@pr_list_limit 200
defp list_open_prs(token) do
args = [
"pr",
"list",
@anvil_repo,
"--state",
"open",
"--limit",
to_string(@pr_list_limit),
"--json"
]
case anvil_cli(token, args) do
{:ok, out} -> decode_prs(out)
{:error, reason} -> abort("could not list open PRs on #{@anvil_repo}: #{reason}")
end
end
@doc """
Pulls the PR list out of the CLI's response.
The payload is an object carrying `pull_requests` alongside pagination, not
a bare array. A shape that cannot be read is an error rather than an empty
list: treating "I could not tell" as "there is no open PR" would open a
duplicate every run.
"""
@spec decode_prs(String.t()) :: [map()]
def decode_prs(json) do
case JSON.decode(json) do
{:ok, %{"pull_requests" => prs}} when is_list(prs) -> prs
{:ok, prs} when is_list(prs) -> prs
_ -> abort("could not parse the PR listing from #{@anvil_repo}:\n#{json}")
end
end
defp anvil_cli(token, args) do
run("anvil", args, ".", [{~c"ANVIL_TOKEN", String.to_charlist(token)}])
end
defp git(args, dir), do: run("git", args, dir, [])
defp run(cmd, args, dir, env) do
opts = [stderr_to_stdout: true, cd: dir]
opts =
if env == [],
do: opts,
else: [{:env, Enum.map(env, fn {k, v} -> {to_string(k), to_string(v)} end)} | opts]
case System.cmd(cmd, args, opts) do
{out, 0} ->
{:ok, String.trim(out)}
{out, code} ->
{:error, "#{cmd} #{Enum.join(args, " ")} exited #{code}: #{String.trim(out)}"}
end
end
end
test/ci/pin_bump_test.exs +284 −0
@@ -1,0 +1,284 @@
# 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.
Code.require_file("../../ci/pin_bump.exs", __DIR__)
defmodule ExGitObjectstore.CI.PinBumpTest do
@moduledoc """
The decidable parts of the pin bump (#81).
The fixtures below are the real shapes from Anvil's `mix.exs` and
`mix.lock`, trimmed. Testing against invented shapes would prove the regexes
match something, not that they match the files this actually rewrites.
"""
use ExUnit.Case, async: true
alias ExGitObjectstore.CI.PinBump
@old "228bb1ed4637ae81b0dc6be4801f86a55eff0888"
@new "5af34d4db754004bf2071bd5b97e28b447fcfc29"
# Trimmed from Anvil's mix.exs, keeping the shape that matters.
defp mix_exs do
"""
defp ex_git_objectstore_dep do
local_path =
System.get_env("EX_GIT_OBJECTSTORE_PATH") ||
Path.expand("../ex_git_objectstore", __DIR__)
if File.dir?(local_path) and System.get_env("EX_GIT_OBJECTSTORE_GIT") in [nil, "0"] do
[path: local_path]
else
[
git: "https://anvil.fangorn.io/fangorn/ex_git_objectstore.git",
ref: "#{@old}"
]
end
end
"""
end
# Trimmed from Anvil's mix.lock — neighbours kept so "nothing else moves"
# is actually observable.
defp mix_lock do
"""
%{
"ecto": {:hex, :ecto, "3.13.2", "abc", [:mix], [], "hexpm", "def"},
"ex_git_objectstore": {:git, "https://anvil.fangorn.io/fangorn/ex_git_objectstore.git", "#{@old}", [ref: "#{@old}"]},
"expo": {:hex, :expo, "1.1.0", "abc", [:mix], [], "hexpm", "def"},
}
"""
end
describe "reading the current pin" do
@tag requirements: ["REQ-CI-002"]
test "finds the SHA mix.exs pins" do
assert {:ok, @old} = PinBump.current_pin(mix_exs())
end
@tag requirements: ["REQ-CI-002"]
test "errors when the dependency is not in the expected shape" do
assert {:error, message} = PinBump.current_pin("defp deps, do: []")
assert message =~ "ref:"
end
end
describe "rewriting mix.exs" do
@tag requirements: ["REQ-CI-002"]
test "moves the ref and leaves everything else alone" do
assert {:ok, bumped} = PinBump.bump_mix_exs(mix_exs(), @new)
assert bumped =~ ~s(ref: "#{@new}")
refute bumped =~ @old
# The surrounding logic must survive untouched — this file decides
# whether Anvil builds against the sibling checkout or the pin.
assert bumped =~ "EX_GIT_OBJECTSTORE_PATH"
assert bumped =~ "[path: local_path]"
assert bumped =~ "git: \"https://anvil.fangorn.io/fangorn/ex_git_objectstore.git\""
# Only the SHA changed.
assert String.replace(bumped, @new, @old) == mix_exs()
end
@tag requirements: ["REQ-CI-002"]
test "refuses a file with no pin rather than writing one it did not find" do
assert {:error, message} = PinBump.bump_mix_exs("defp deps, do: []", @new)
assert message =~ "no `ref:"
end
@tag requirements: ["REQ-CI-002"]
test "refuses a file with more than one pin rather than guessing" do
doubled = mix_exs() <> ~s(\n ref: "#{String.duplicate("b", 40)}"\n)
assert {:error, message} = PinBump.bump_mix_exs(doubled, @new)
assert message =~ "refusing to guess"
end
@tag requirements: ["REQ-CI-002"]
test "rejects anything that is not a 40-hex SHA" do
for bad <- ["main", "", "abc", String.duplicate("z", 40), nil] do
assert {:error, message} = PinBump.bump_mix_exs(mix_exs(), bad)
assert message =~ "hex SHA"
end
end
end
describe "rewriting mix.lock" do
@tag requirements: ["REQ-CI-002"]
test "moves both SHAs in the entry and no other line" do
assert {:ok, bumped} = PinBump.bump_mix_lock(mix_lock(), @new)
assert bumped =~
~s("ex_git_objectstore": {:git, "https://anvil.fangorn.io/fangorn/ex_git_objectstore.git", "#{@new}", [ref: "#{@new}"]},)
refute bumped =~ @old
# Neighbouring entries are untouched.
assert bumped =~ ~s("ecto": {:hex, :ecto, "3.13.2")
assert bumped =~ ~s("expo": {:hex, :expo, "1.1.0")
assert String.replace(bumped, @new, @old) == mix_lock()
end
@tag requirements: ["REQ-CI-002"]
test "refuses a lock without the entry" do
lock = ~s(%{\n "ecto": {:hex, :ecto, "3.13.2", "abc", [:mix], [], "hexpm", "def"},\n}\n)
assert {:error, message} = PinBump.bump_mix_lock(lock, @new)
assert message =~ "expected shape"
end
@tag requirements: ["REQ-CI-002"]
test "rejects anything that is not a 40-hex SHA" do
assert {:error, message} = PinBump.bump_mix_lock(mix_lock(), "main")
assert message =~ "hex SHA"
end
end
describe "idempotency" do
@tag requirements: ["REQ-CI-001"]
test "updates the existing pin-bump PR when one is open" do
prs = [
%{"number" => 240, "head_branch" => "someone/unrelated"},
%{"number" => 241, "head_branch" => PinBump.branch()}
]
assert {:update, 241} = PinBump.pr_action(prs)
end
@tag requirements: ["REQ-CI-001"]
test "opens a new one when none is" do
assert :create = PinBump.pr_action([])
assert :create = PinBump.pr_action([%{"number" => 1, "head_branch" => "other"}])
end
# The whole point is that repeated merges do not pile up PRs, so this must
# never rewrite a PR that is not ours.
@tag requirements: ["REQ-CI-001"]
test "never touches a PR opened from another branch" do
prs = [
%{"number" => 10, "head_branch" => "feat/something"},
%{"number" => 11, "head_branch" => "chore/bump-something-else"}
]
assert :create = PinBump.pr_action(prs)
end
@tag requirements: ["REQ-CI-001"]
test "picks the lowest number so the choice is stable across runs" do
prs = [
%{"number" => 300, "head_branch" => PinBump.branch()},
%{"number" => 200, "head_branch" => PinBump.branch()}
]
assert {:update, 200} = PinBump.pr_action(prs)
end
end
describe "reading the PR listing" do
# The real payload from `anvil pr list --json`: an object carrying
# `pull_requests` alongside pagination, not a bare array. Decoding this
# wrong reads as "no PR is open" and opens a duplicate every run.
@tag requirements: ["REQ-CI-001"]
test "pulls the list out of the paginated envelope" do
json = """
{
"page": 1,
"per_page": 30,
"pull_requests": [
{"number": 242, "head_branch": "fix/something", "state": "open"},
{"number": 243, "head_branch": "#{PinBump.branch()}", "state": "open"}
]
}
"""
prs = PinBump.decode_prs(json)
assert length(prs) == 2
assert {:update, 243} = PinBump.pr_action(prs)
end
@tag requirements: ["REQ-CI-001"]
test "accepts a bare array too" do
json = ~s([{"number": 1, "head_branch": "#{PinBump.branch()}"}])
assert {:update, 1} = PinBump.pr_action(PinBump.decode_prs(json))
end
@tag requirements: ["REQ-CI-001"]
test "an empty listing means there is genuinely nothing open" do
assert :create = PinBump.pr_action(PinBump.decode_prs(~s({"pull_requests": []})))
end
end
describe "the cross-repo credential" do
@tag requirements: ["REQ-CI-003"]
test "is read from its own variable, not the injected job token" do
refute PinBump.token_var() == "ANVIL_TOKEN",
"CI secrets merge over the job environment, so reusing ANVIL_TOKEN " <>
"would replace the injected per-job token for the whole job"
assert {:ok, "tok"} = PinBump.fetch_token(%{PinBump.token_var() => "tok"})
end
@tag requirements: ["REQ-CI-003"]
test "absence is an error naming the secret, the repo and the permission" do
for env <- [%{}, %{PinBump.token_var() => ""}, %{PinBump.token_var() => " "}] do
assert {:error, message} = PinBump.fetch_token(env)
assert message =~ PinBump.token_var()
assert message =~ PinBump.anvil_repo()
assert message =~ "contents: write"
end
end
@tag requirements: ["REQ-CI-003"]
test "the injected job token alone is not enough" do
assert {:error, _} = PinBump.fetch_token(%{"ANVIL_TOKEN" => "job-scoped"})
end
end
describe "the generated pull request" do
@tag requirements: ["REQ-CI-002"]
test "shows a reviewer which commits are being pulled in" do
body = PinBump.pr_body(@old, @new, ["abc1234 fix(a): one", "def5678 feat(b): two"])
assert body =~ "- abc1234 fix(a): one"
assert body =~ "- def5678 feat(b): two"
assert body =~ @old
assert body =~ @new
end
@tag requirements: ["REQ-CI-002"]
test "says so plainly when the commit range came back empty" do
body = PinBump.pr_body(@old, @new, [])
refute body =~ "- \n"
assert body =~ "no commits listed"
end
@tag requirements: ["REQ-CI-001"]
test "states that it is never merged automatically" do
body = PinBump.pr_body(@old, @new, ["abc1234 x"])
# Assert on phrases that cannot straddle a line wrap.
assert body =~ "never merged"
assert body =~ "Anvil's CI is the gate"
end
@tag requirements: ["REQ-CI-002"]
test "the title carries the short SHA" do
assert PinBump.pr_title(@new) =~ String.slice(@new, 0, 8)
end
end
end
test/ex_git_objectstore/integration/git_repo_test.exs +6 −1
@@ -159,7 +159,12 @@
# Write to a temp file and hash with git
tmp = Path.join(System.tmp_dir!(), "verify_#{:erlang.unique_integer([:positive])}")
File.write!(tmp, "verification test\n")
git_sha = System.cmd("git", ["hash-object", tmp]) |> elem(0) |> String.trim()
git_sha =
System.cmd("git", ["hash-object", tmp], cd: Path.dirname(tmp))
|> elem(0)
|> String.trim()
File.rm!(tmp)
assert our_sha == git_sha
test/ex_git_objectstore/integration/protocol_interop_test.exs +2 −1
@@ -809,7 +809,8 @@
pack_file = Path.join(base, "test_#{:erlang.unique_integer([:positive])}.pack")
File.write!(pack_file, pack_data)
{output, status} = System.cmd("git", ["index-pack", pack_file], stderr_to_stdout: true)
{output, status} =
System.cmd("git", ["index-pack", pack_file], cd: base, stderr_to_stdout: true)
if status != 0 do
flunk("git index-pack failed (exit #{status}): #{output}")
test/ex_git_objectstore/protocol/upload_pack_reuse_test.exs +1 −1
@@ -101,7 +101,7 @@
assert status == 0, "git index-pack --strict rejected the pack"
idx = String.replace_suffix(pack_file, ".pack", ".idx")
{vp, 0} = System.cmd("git", ["verify-pack", "-v", idx], stderr_to_stdout: true)
{vp, 0} = System.cmd("git", ["verify-pack", "-v", idx], cd: dir, stderr_to_stdout: true)
vp
|> String.split("\n", trim: true)
test/ex_git_objectstore/protocol/upload_pack_v2_test.exs +4 −3
@@ -726,13 +726,14 @@
File.write!(pack_path, pack_data)
# Verify git can parse the pack by indexing it
{output, exit_code} = System.cmd("git", ["index-pack", pack_path], stderr_to_stdout: true)
{output, exit_code} =
System.cmd("git", ["index-pack", pack_path], cd: tmp_dir, stderr_to_stdout: true)
assert exit_code == 0, "git index-pack failed: #{output}"
# Now verify-pack should work with the generated idx
{output2, exit_code2} =
System.cmd("git", ["verify-pack", "-v", pack_path], cd: tmp_dir, stderr_to_stdout: true)
System.cmd("git", ["verify-pack", "-v", pack_path], stderr_to_stdout: true)
assert exit_code2 == 0, "git verify-pack failed: #{output2}"
assert String.contains?(output2, commit_sha)
@@ -1086,7 +1087,7 @@
# asserting it passes here pins the regression to a real-git
# behavior, not just our own parser.
{output, exit_code} =
System.cmd("git", ["index-pack", "-v", pack_path], cd: tmp_dir, stderr_to_stdout: true)
System.cmd("git", ["index-pack", "-v", pack_path], stderr_to_stdout: true)
assert exit_code == 0,
"git index-pack rejected streamed pack: #{output}\npack size #{byte_size(pack_body)} bytes"
test/ex_git_objectstore/test_isolation_test.exs +208 −4
@@ -14,16 +14,53 @@
defmodule ExGitObjectstore.TestIsolationTest do
@moduledoc """
Guards the test-suite isolation invariant: a `git` command run by this suite
must never be able to reach — and therefore mutate — any repository other
than its own fixture.
There are three routes out of a fixture, and each needs its own guard:
1. **Discovery upward** from a scratch directory that is not a repository,
into the project's real `.git`. Guarded by `GIT_CEILING_DIRECTORIES`
(`test/test_helper.exs`).
2. **An inherited `GIT_DIR`** (or another redirect variable), which
bypasses discovery entirely, so the ceiling never gets consulted. This
is how `.githooks/pre-push` — which runs `mix test` with git's own
environment exported into it — corrupted developer checkouts (#75).
Guarded by `ExGitObjectstore.Test.GitEnv`.
3. **No working directory**, so the command runs in the project root and
finds this project's repository at cwd, with no ascent for the ceiling
to stop. Guarded by `GitEnv.cmd/3` requiring a directory.
The tests below use a throwaway repository as the *victim* rather than this
project, so a regression fails an assertion instead of corrupting a
developer's checkout.
Guards the test-suite isolation invariant: a `git` command run in a scratch
directory must never be able to discover — and therefore mutate — this
project's real repository. See `test/test_helper.exs`.
"""
# Not async: these tests set and clear GIT_DIR in the *process-global*
# environment to reproduce what the pre-push hook does, and every git
# subprocess in the suite inherits it. Run concurrently, the window between
# `System.put_env/1` and the cleanup leaks the poison into whatever async
# test happens to shell out to git at that moment — which is exactly how
# this first showed up, as a neighbouring test failing with "GIT_WORK_TREE
# not allowed without specifying GIT_DIR".
use ExUnit.Case, async: false
alias ExGitObjectstore.Test.GitEnv
use ExUnit.Case, async: true
test "GIT_CEILING_DIRECTORIES is set to the project root for all git children" do
assert System.get_env("GIT_CEILING_DIRECTORIES") == File.cwd!()
end
@tag requirements: ["REQ-GIT-084"]
test "the test process carries no inherited git redirect variables" do
# `.githooks/pre-push` runs the suite with git's environment exported into
# it. Anything still set here is inherited by every git subprocess.
for var <- GitEnv.redirect_vars() do
assert System.get_env(var) == nil,
"#{var} is set in the test process; every git subprocess inherits it"
end
end
@tag :tmp_dir
test "git cannot escape a scratch dir upward into the project repo", %{tmp_dir: tmp_dir} do
# `tmp_dir` is under the project tree but is NOT a git repo. Before the
@@ -46,5 +83,172 @@
refute String.trim(toplevel) == File.cwd!(),
"git discovered the project repo from a scratch dir: #{toplevel}"
end
describe "an inherited GIT_DIR (#75)" do
@tag :tmp_dir
@tag requirements: ["REQ-GIT-084"]
test "cannot redirect fixture commands into another repository", %{tmp_dir: tmp_dir} do
victim = victim_repo(tmp_dir)
before = fingerprint(victim)
fixture = init_fixture(tmp_dir, "fixture")
# Exactly what the pre-push hook leaves in the environment.
poisoned = [
{"GIT_DIR", Path.join(victim, ".git")},
{"GIT_WORK_TREE", victim}
]
System.put_env(poisoned)
try do
# A fixture doing ordinary fixture things — the same commits, config
# writes and ref updates the suite performs in its scratch repos.
File.write!(Path.join(fixture, "fixture.txt"), "fixture content\n")
GitEnv.cmd(fixture, ["add", "fixture.txt"])
GitEnv.cmd(fixture, ["commit", "-qm", "fixture commit"])
GitEnv.cmd(fixture, ["config", "remote.origin.url", "http://127.0.0.1:1/repo"])
GitEnv.cmd(fixture, ["config", "core.bare", "true"])
GitEnv.cmd(fixture, ["config", "lfs.url", "http://127.0.0.1:2/info/lfs"])
GitEnv.cmd(fixture, ["update-ref", "refs/remotes/origin/main", "HEAD"])
after
Enum.each(poisoned, fn {k, _} -> System.delete_env(k) end)
end
assert fingerprint(victim) == before,
"an inherited GIT_DIR redirected fixture commands into another repository"
end
@tag :tmp_dir
@tag requirements: ["REQ-GIT-084"]
test "does not decide which repository a command resolves to", %{tmp_dir: tmp_dir} do
victim = victim_repo(tmp_dir)
fixture = init_fixture(tmp_dir, "fixture2")
System.put_env("GIT_DIR", Path.join(victim, ".git"))
try do
# `--show-toplevel` reports which repository the command actually
# resolved to — the direct question, with no mutation involved.
resolved = GitEnv.cmd!(fixture, ["rev-parse", "--show-toplevel"])
refute Path.expand(resolved) == Path.expand(victim),
"GitEnv.cmd/3 resolved to the inherited GIT_DIR instead of the fixture"
assert Path.expand(resolved) == Path.expand(fixture)
after
System.delete_env("GIT_DIR")
end
end
end
describe "a git command with no working directory (#75)" do
@tag requirements: ["REQ-GIT-084"]
test "GitEnv.cmd/3 cannot be called without one" do
# The failure this prevents is `System.cmd("git", args)` with no `cd:`,
# which runs in the project root and finds this project's repository at
# cwd — where the ceiling cannot help, because nothing ascends.
# apply/3 so the compiler does not flag the deliberate bad call.
assert_raise FunctionClauseError, fn -> apply(GitEnv, :cmd, [nil, ["status"]]) end
end
@tag requirements: ["REQ-GIT-084"]
test "no test file shells out to git without pinning it to a directory" do
offenders =
"test/**/*.{ex,exs}"
|> Path.wildcard()
|> Enum.flat_map(&scan_for_undirected_git/1)
assert offenders == [],
"""
These call git without pinning it to a directory, so they run in
the project root and operate on this project's own repository.
Use ExGitObjectstore.Test.GitEnv.cmd/3, or pass an explicit
`cd:` / `-C <dir>`:
#{Enum.map_join(offenders, "\n", &" #{&1}")}
"""
end
end
# ── Helpers ─────────────────────────────────────────────────────────────
# A self-contained repository standing in for "some repository that is not
# the fixture" — the role the developer's real checkout plays in the bug.
defp victim_repo(tmp_dir) do
dir = init_fixture(tmp_dir, "victim")
{_, 0} = GitEnv.cmd(dir, ["config", "remote.origin.url", "git@example.invalid:v/v.git"])
File.write!(Path.join(dir, "keep.txt"), "original\n")
{_, 0} = GitEnv.cmd(dir, ["add", "keep.txt"])
{_, 0} = GitEnv.cmd(dir, ["commit", "-qm", "victim baseline"])
dir
end
defp init_fixture(tmp_dir, name) do
dir = Path.join(tmp_dir, name)
File.mkdir_p!(dir)
{_, 0} = GitEnv.cmd(dir, ["init", "-q", "."])
{_, 0} = GitEnv.cmd(dir, ["config", "user.email", "t@test"])
{_, 0} = GitEnv.cmd(dir, ["config", "user.name", "t"])
{_, 0} = GitEnv.cmd(dir, ["config", "commit.gpgsign", "false"])
dir
end
defp fingerprint(repo) do
%{
bare: GitEnv.cmd!(repo, ["config", "--get", "core.bare"]),
origin: GitEnv.cmd!(repo, ["config", "--get", "remote.origin.url"]),
lfs: GitEnv.cmd(repo, ["config", "--get", "lfs.url"]) |> elem(0) |> to_string(),
head: GitEnv.cmd!(repo, ["rev-parse", "HEAD"]),
refs: GitEnv.cmd!(repo, ["for-each-ref", "--format=%(refname) %(objectname)"]),
commits: GitEnv.cmd!(repo, ["rev-list", "--count", "--all"])
}
end
# Flags `System.cmd("git", ...)` invocations whose options carry no `cd:`.
# Deliberately textual: the point is to catch a call site added later that
# forgets, and the shape is uniform across this suite.
#
# The needle is assembled at runtime rather than written out, so this
# scanner does not match its own source and report itself.
@needle "System.cmd(" <> ~s("git")
# Generous enough that reformatting cannot push the options out of view —
# a 5-line window silently started reporting this very function once
# `mix format` inserted a blank line above its `cd:` check.
@window_lines 10
defp scan_for_undirected_git(path) do
lines = path |> File.read!() |> String.split("\n")
lines
|> Enum.with_index(1)
|> Enum.filter(fn {line, _} -> String.contains?(line, @needle) end)
|> Enum.reject(fn {_, idx} ->
# The options may trail onto the following lines.
window = lines |> Enum.slice(idx - 1, @window_lines) |> Enum.join("\n")
String.contains?(window, "cd:") or String.contains?(window, ~s|"-C"|) or
repo_less?(path, window)
end)
|> Enum.map(fn {_, idx} -> "#{path}:#{idx}" end)
end
# Commands that resolve no repository at all, so they have nothing to
# corrupt: version probes, and `git init <dir>` / `merge-file`, which take
# their target as an explicit path argument.
defp repo_less?(path, window) do
String.ends_with?(path, "test/support/git_env.ex") or
String.ends_with?(path, "test/support/git_daemon.ex") or
String.contains?(window, ~s|"lfs", "version"|) or
String.contains?(window, ~s|"--version"|) or
String.contains?(window, ~s|"init", "-q"|) or
String.contains?(window, ~s|"init", "--bare"|) or
String.contains?(window, ~s|"merge-file"|)
end
end
test/support/git_daemon.ex +10 −1
@@ -44,6 +44,8 @@
in the failure modes we're targeting.
"""
alias ExGitObjectstore.Test.GitEnv
alias ExGitObjectstore.Protocol.{PktLine, ReceivePack, UploadPackV2}
# --- public API ---
@@ -105,7 +107,14 @@
runs. Returns `{output, exit_code}`.
"""
def git_at(dir, args, extra_env \\ []) do
# The environment is scrubbed of GIT_DIR and friends (#75): under
# `.githooks/pre-push` the suite inherits git's own environment, which
# would otherwise redirect every one of these commands at the repository
# being pushed regardless of `cd`.
env = [{"GIT_TERMINAL_PROMPT", "0"} | extra_env]
#
# `dir` stays optional because `git clone <url> <dest>` names its target
# explicitly and operates on no existing repository.
env = GitEnv.clean_env([{"GIT_TERMINAL_PROMPT", "0"} | extra_env])
opts = [stderr_to_stdout: true, env: env]
opts = if dir, do: [{:cd, dir} | opts], else: opts
test/support/git_env.ex +149 −0
@@ -1,0 +1,149 @@
# 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
test/test_helper.exs +15 −0
@@ -26,4 +26,19 @@
# git child of the test process, so it is set once here, globally.
System.put_env("GIT_CEILING_DIRECTORIES", File.cwd!())
# Second guard, for the route the ceiling cannot cover (#75).
#
# `.githooks/pre-push` runs `mix test`, and git exports GIT_DIR, GIT_WORK_TREE
# and friends into hook processes. Those variables bypass repository
# *discovery* entirely — git uses them directly — so the ceiling above never
# gets consulted, and every git subprocess in the suite is aimed at the
# repository being pushed. That is how developer checkouts ended up with
# `core.bare=true`, an origin pointing at a fixture's HTTP daemon, force-updated
# branch tips and ~140 fixture commits.
#
# Dropping them here means nothing can inherit a redirection from whatever
# launched the suite, whether or not a given call site goes through
# `ExGitObjectstore.Test.GitEnv.cmd/3`.
ExGitObjectstore.Test.GitEnv.scrub_inherited!()
ExUnit.start(exclude: [:s3])