git: pin the git output contract and declare a minimum git version #524

Closed
opened 2026-09-16 00:24:38 +00:00 by hexajon · 2 comments
Owner

Phase 1 of the git-currency work. See the epic for the full picture.

What happens

madtea shells out to the git binary. There is no go-git and no libgit2 in go.mod, and internal/service/git/ holds about 25 source files that run git and parse what it prints. So git is an undeclared runtime dependency with an unpinned output contract.

Two concrete gaps:

  1. Every --porcelain call site uses the bare, unversioned form. There are 35 of them across internal/. None use --porcelain=v1. Git documents the unversioned default as free to change, and tells scripts to name the version they parse.
  2. No minimum git version is documented or checked. Nothing in the tree reads git --version for a gate. The only --version calls are madtea's own binary and the installed-binary check at internal/cmd/install/install.go:976.

The failure mode is quiet. A user on a newer git gets a --porcelain parse that returns the wrong thing in status, stage, or worktree, and nothing reports an error.

Fix

  • Pin every --porcelain site to --porcelain=v1, so the parsed format is named, not inherited.
  • Prefer plumbing with an explicit --format where a porcelain parse is doing plumbing work. The explicit --format= sites are already the stable ones.
  • Pick a minimum git version, document it as a runtime requirement, and check it once per run. On a git below the floor, fail with a message that names the found version and the required one.
  • Record the output contract, so a later change knows what it may rely on.

Acceptance

  • No bare --porcelain remains in internal/, and a test enforces that.
  • The minimum version is documented in the README requirements and checked at runtime.
  • A git below the floor produces a clear error, not a mis-parse.
Phase 1 of the git-currency work. See the epic for the full picture. ## What happens madtea shells out to the `git` binary. There is no `go-git` and no libgit2 in `go.mod`, and `internal/service/git/` holds about 25 source files that run git and parse what it prints. So git is an undeclared runtime dependency with an unpinned output contract. Two concrete gaps: 1. **Every `--porcelain` call site uses the bare, unversioned form.** There are 35 of them across `internal/`. None use `--porcelain=v1`. Git documents the unversioned default as free to change, and tells scripts to name the version they parse. 2. **No minimum git version is documented or checked.** Nothing in the tree reads `git --version` for a gate. The only `--version` calls are madtea's own binary and the installed-binary check at `internal/cmd/install/install.go:976`. The failure mode is quiet. A user on a newer git gets a `--porcelain` parse that returns the wrong thing in `status`, `stage`, or `worktree`, and nothing reports an error. ## Fix - Pin every `--porcelain` site to `--porcelain=v1`, so the parsed format is named, not inherited. - Prefer plumbing with an explicit `--format` where a porcelain parse is doing plumbing work. The explicit `--format=` sites are already the stable ones. - Pick a minimum git version, document it as a runtime requirement, and check it once per run. On a git below the floor, fail with a message that names the found version and the required one. - Record the output contract, so a later change knows what it may rely on. ## Acceptance - No bare `--porcelain` remains in `internal/`, and a test enforces that. - The minimum version is documented in the README requirements and checked at runtime. - A git below the floor produces a clear error, not a mis-parse.
Author
Owner

Decided: the minimum git version is 2.38.0. There is one floor for every command, and no per-feature checks.

Why 2.38

madtea uses two git features newer than 2.29:

  • git merge-tree --write-tree in internal/cmd/pr/conflicts.go:74 needs 2.38 (RelNotes 2.38.0).
  • git rev-parse --end-of-options in internal/service/git/refinspect.go:587 needs 2.30 (RelNotes 2.30.0).

All other flags found are older: --end-of-options on other commands (2.24), branch --show-current (2.22), sparse-checkout list (2.25).

LTS git versions

Distro git Meets 2.38
Ubuntu 20.04 2.25.1 no
Ubuntu 22.04 2.34.1 no
Ubuntu 24.04 2.43.0 yes
Ubuntu 26.04 2.53.0 yes
Debian 11 2.30.2 no
Debian 12 2.39.5 yes
Debian 13 2.47.3 yes
RHEL 8.10 / 9.4 2.43.0 (2.39 before) yes
RHEL 10.0 2.45 yes

Also in scope for this issue

  • Locale: madtea sets no locale when it runs git, but 14 places search git's stderr for English text (CONFLICT in merge, rebase, cherry-pick, revert, and stash, plus already exists and non-fast-forward). Git translates these messages (the German catalog has KONFLIKT (%s): Merge-Konflikt in %s). Set LC_ALL=C on every git call, as lazygit (LANG=C LC_ALL=C LC_MESSAGES=C) and git-lfs (LC_ALL=C.UTF-8) do.
  • Output formats: git(1) says plumbing output is stable and porcelain output may change. git status --porcelain=v1 and git worktree list --porcelain are the documented stable forms, and the docs recommend -z.

Not yet checked

  • Git 2.43.1 fixed git $cmd --end-of-options --rev -- --path for some commands. madtea uses that form with log and grep. The #525 matrix run on 2.38 will show whether it works there.
  • Arguments that users pass through madtea git ..., the -c config keys, and format placeholders were not checked against git versions.
  • The #525 matrix should include 2.38.0 (the floor) and the latest release. lazygit's CI is a working example.
Decided: the minimum git version is 2.38.0. There is one floor for every command, and no per-feature checks. ## Why 2.38 madtea uses two git features newer than 2.29: - `git merge-tree --write-tree` in `internal/cmd/pr/conflicts.go:74` needs 2.38 (RelNotes 2.38.0). - `git rev-parse --end-of-options` in `internal/service/git/refinspect.go:587` needs 2.30 (RelNotes 2.30.0). All other flags found are older: `--end-of-options` on other commands (2.24), `branch --show-current` (2.22), `sparse-checkout list` (2.25). ## LTS git versions | Distro | git | Meets 2.38 | |---|---|---| | Ubuntu 20.04 | 2.25.1 | no | | Ubuntu 22.04 | 2.34.1 | no | | Ubuntu 24.04 | 2.43.0 | yes | | Ubuntu 26.04 | 2.53.0 | yes | | Debian 11 | 2.30.2 | no | | Debian 12 | 2.39.5 | yes | | Debian 13 | 2.47.3 | yes | | RHEL 8.10 / 9.4 | 2.43.0 (2.39 before) | yes | | RHEL 10.0 | 2.45 | yes | ## Also in scope for this issue - **Locale:** madtea sets no locale when it runs git, but 14 places search git's stderr for English text (`CONFLICT` in merge, rebase, cherry-pick, revert, and stash, plus `already exists` and `non-fast-forward`). Git translates these messages (the German catalog has `KONFLIKT (%s): Merge-Konflikt in %s`). Set `LC_ALL=C` on every git call, as lazygit (`LANG=C LC_ALL=C LC_MESSAGES=C`) and git-lfs (`LC_ALL=C.UTF-8`) do. - **Output formats:** git(1) says plumbing output is stable and porcelain output may change. `git status --porcelain=v1` and `git worktree list --porcelain` are the documented stable forms, and the docs recommend `-z`. ## Not yet checked - Git 2.43.1 fixed `git $cmd --end-of-options --rev -- --path` for some commands. madtea uses that form with `log` and `grep`. The #525 matrix run on 2.38 will show whether it works there. - Arguments that users pass through `madtea git ...`, the `-c` config keys, and format placeholders were not checked against git versions. - The #525 matrix should include 2.38.0 (the floor) and the latest release. lazygit's CI is a working example.
Author
Owner

Closed by PR 529, merged to main as 975e6d15.

What landed:

  • internal/gitexec builds every git child process and puts LC_ALL=C last in the environment. Every direct exec.Command("git", ...) call now uses it.
  • gitexec.EnsureMinVersion checks git 2.38.0 once per process, on the plain runners, the unauthenticated clone, and both transports. Below the floor the error names the found version and the required version.
  • git status uses --porcelain=v1. git worktree list keeps the bare flag, because git offers no versioned form there and documents that format as stable. The record states this exception.
  • A go/ast guard test fails on a new direct git call and on a new bare --porcelain. A UTF-8 test proves a commit subject and a file path read back unchanged under LC_ALL=C.
  • README states git 2.38.0 or newer as a runtime requirement, at both the install step and the build-from-source step.

The decision is recorded in docs/decisions/0038-git-cli-minimum-version-and-output-contract.md (accepted).

Gate: PASS, all 18 checks, on the merged tree.

Open follow-ups: the git version matrix in CI (issue 525), and the 17 Run* variants (issue 527).

Closed by PR 529, merged to main as 975e6d15. What landed: - `internal/gitexec` builds every git child process and puts `LC_ALL=C` last in the environment. Every direct `exec.Command("git", ...)` call now uses it. - `gitexec.EnsureMinVersion` checks git 2.38.0 once per process, on the plain runners, the unauthenticated clone, and both transports. Below the floor the error names the found version and the required version. - `git status` uses `--porcelain=v1`. `git worktree list` keeps the bare flag, because git offers no versioned form there and documents that format as stable. The record states this exception. - A go/ast guard test fails on a new direct git call and on a new bare `--porcelain`. A UTF-8 test proves a commit subject and a file path read back unchanged under `LC_ALL=C`. - README states git 2.38.0 or newer as a runtime requirement, at both the install step and the build-from-source step. The decision is recorded in `docs/decisions/0038-git-cli-minimum-version-and-output-contract.md` (accepted). Gate: PASS, all 18 checks, on the merged tree. Open follow-ups: the git version matrix in CI (issue 525), and the 17 `Run*` variants (issue 527).
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Reference
sixfold-space/madtea#524
No description provided.