From 582d25cd7409b3c5523bddbfc8786fc4ce8c6ec7 Mon Sep 17 00:00:00 2001 From: manjari Date: Thu, 22 Oct 2026 05:35:13 +0000 Subject: [PATCH] fix(git): Run the pinned git's helper commands from its own directory --- platform/git/exec/gitexec.go | 23 +++++++++++++++++++-- platform/git/exec/gitexec_test.go | 33 +++++++++++++++++++++++++++++++ platform/git/repo/repo.go | 6 +++++- 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/platform/git/exec/gitexec.go b/platform/git/exec/gitexec.go index 95c515fe3..70108ffa4 100644 --- a/platform/git/exec/gitexec.go +++ b/platform/git/exec/gitexec.go @@ -135,14 +135,33 @@ func Env(opts EnvOptions) []string { // Command builds a git invocation in dir with the ambient environment removed. // An empty dir runs in the current working directory. PATH is passed so git can -// find its helpers; nothing else the host sets reaches the command. +// find its helpers, with git's own directory first; nothing else the host sets +// reaches the command. func Command(ctx context.Context, git, dir string, args ...string) *exec.Cmd { cmd := exec.CommandContext(ctx, git, args...) cmd.Dir = dir - cmd.Env = Env(EnvOptions{Literal: []string{"PATH=" + os.Getenv("PATH")}}) + cmd.Env = Env(EnvOptions{Literal: []string{"PATH=" + PathWithGitFirst(git)}}) return cmd } +// PathWithGitFirst returns PATH with the directory of git in front, for a +// command that builds its own environment instead of using Command. Git runs +// some of its own commands by name, such as upload-pack and maintenance, and a +// git built without a usable exec path finds them on PATH. Without this, a +// newer pinned git can end up running an older one found elsewhere on PATH, +// which fails on options it does not know. A git given without a directory is +// found through PATH already, so it is left alone. +func PathWithGitFirst(git string) string { + path := os.Getenv("PATH") + if !filepath.IsAbs(git) { + return path + } + if path == "" { + return filepath.Dir(git) + } + return filepath.Dir(git) + string(os.PathListSeparator) + path +} + // Run executes a git command and discards its output, reporting stderr in the // error so a failure says what git said rather than only that it exited. func Run(ctx context.Context, git, dir string, args ...string) error { diff --git a/platform/git/exec/gitexec_test.go b/platform/git/exec/gitexec_test.go index 610b0dd98..d00f150b1 100644 --- a/platform/git/exec/gitexec_test.go +++ b/platform/git/exec/gitexec_test.go @@ -19,6 +19,7 @@ import ( "errors" "os" "os/exec" + "path/filepath" "strings" "testing" @@ -228,3 +229,35 @@ func TestCommandFailure(t *testing.T) { assert.Empty(t, commandErr.Operation()) }) } + +// A git that runs another git by name has to get itself, not whichever git is +// first on the caller's PATH. +func TestCommand_RunsHelpersFromGitsOwnDirectory(t *testing.T) { + dir := t.TempDir() + fakeGit := filepath.Join(dir, "git") + require.NoError(t, os.WriteFile(fakeGit, []byte("#!/bin/sh\ncommand -v git\n"), 0o755)) + + t.Setenv("PATH", t.TempDir()+string(os.PathListSeparator)+os.Getenv("PATH")) + + got, err := Output(context.Background(), fakeGit, "") + require.NoError(t, err) + assert.Equal(t, fakeGit, got) +} + +func TestPathWithGitFirst(t *testing.T) { + t.Setenv("PATH", "/usr/bin:/bin") + tests := []struct { + name string + git string + want string + }{ + {name: "an absolute git goes first", git: "/opt/pinned/git", want: "/opt/pinned:/usr/bin:/bin"}, + {name: "a bare name is found through PATH already", git: "git", want: "/usr/bin:/bin"}, + {name: "a relative path is left alone", git: "../pinned/git", want: "/usr/bin:/bin"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, PathWithGitFirst(tt.git)) + }) + } +} diff --git a/platform/git/repo/repo.go b/platform/git/repo/repo.go index bbed86167..542c6bf65 100644 --- a/platform/git/repo/repo.go +++ b/platform/git/repo/repo.go @@ -220,7 +220,11 @@ func (r *Repo) MergeBase(ctx context.Context, a, b string) (string, error) { func (r *Repo) command(ctx context.Context, args ...string) *exec.Cmd { cmd := exec.CommandContext(ctx, r.cfg.Git, args...) cmd.Dir = r.cfg.Path - cmd.Env = gitexec.Env(gitexec.EnvOptions{Transport: true, Passthrough: []string{"HOME"}}) + cmd.Env = gitexec.Env(gitexec.EnvOptions{ + Transport: true, + Passthrough: []string{"HOME"}, + Literal: []string{"PATH=" + gitexec.PathWithGitFirst(r.cfg.Git)}, + }) return cmd }