From 7aa95c5ea8fcb1918c8c4ef3a3cab9e8ef76a6f2 Mon Sep 17 00:00:00 2001 From: Pujitha Paladugu <10557236+pujitha24@users.noreply.github.com> Date: Sun, 9 Aug 2026 17:11:06 -0700 Subject: [PATCH 1/2] Persist HTTP basic auth credentials for git operations after clone Motivation: When a piped git repository is configured with HTTPS username/password (PAT) authentication, pkg/git/client.go's Client.Clone() only passed the Authorization header as a one-off `-c http.extraHeader=...` flag to the `git clone --mirror` / `git fetch` commands used to populate its internal repo cache. That flag is never persisted, so the Repo object handed back to callers (a worktree checked out from the cache, with origin rewritten to the real HTTPS remote URL) carries no credentials at all. Components such as the event watcher call repo.Pull() repeatedly on that same object over time, and Pull/Push/ MergeRemoteBranch/CheckoutPullRequest in pkg/git/repo.go issue git commands straight at the remote with no auth args, so every one of them fails with "fatal: could not read Username for 'https://github.com': No such device or address" -- reproducing exactly the error from the report. Approach: After Client.Clone() checks out the destination repo, persist the basic-auth header into that repo's git config via a new repo.setHTTPAuthHeader method (`git config http.extraHeader `), so every later git command run in that directory picks up the credentials automatically instead of relying on a one-off CLI flag. repo.CopyToModify (used to make a scratch copy for committing and pushing changes) does a plain local `git clone`, which doesn't carry over custom config keys, so the header is propagated there too before CopyToModify's initial fetch. Validation: Ran `go build ./...` (whole main module, passes) and `go test ./pkg/git/...` (passes, including three new/extended tests: TestClonePersistsHTTPAuthHeader, Test_setHTTPAuthHeader, and an extended TestCopyToModify). Confirmed TestClonePersistsHTTPAuthHeader fails against the pre-fix code: with the new persistence call temporarily removed from Clone(), the test fails because `git config --get http.extraHeader` finds nothing in the checked-out repo; restoring the fix makes it pass again. This is a local git-config assertion, not a live HTTPS clone against a real PAT-protected remote, but it directly demonstrates the exact defect: credentials silently dropped after the initial clone. Report: https://github.com/pipe-cd/pipecd/issues/6453 Signed-off-by: Pujitha Paladugu <10557236+pujitha24@users.noreply.github.com> --- pkg/git/client.go | 20 ++++++++++++--- pkg/git/client_test.go | 38 ++++++++++++++++++++++++++++ pkg/git/repo.go | 25 +++++++++++++++++++ pkg/git/repo_test.go | 56 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 136 insertions(+), 3 deletions(-) diff --git a/pkg/git/client.go b/pkg/git/client.go index ed583dd290..ac39938fb8 100644 --- a/pkg/git/client.go +++ b/pkg/git/client.go @@ -33,6 +33,14 @@ const ( defaultEmail = "pipecd.dev@gmail.com" ) +// basicAuthHeader builds the value of an HTTP "Authorization" header +// for basic authentication with the given username and password. +func basicAuthHeader(username, password string) string { + token := fmt.Sprintf("%s:%s", username, password) + encodedToken := base64.StdEncoding.EncodeToString([]byte(token)) + return fmt.Sprintf("Authorization: Basic %s", encodedToken) +} + // Client is a git client for cloning/fetching git repo. // It keeps a local cache for faster future cloning. type Client interface { @@ -148,9 +156,7 @@ func (c *client) Clone(ctx context.Context, repoID, remote, branch, destination _, err, _ := c.repoSingleFlights.Do(repoID, func() (any, error) { authArgs := []string{} if c.username != "" && c.password != "" { - token := fmt.Sprintf("%s:%s", c.username, c.password) - encodedToken := base64.StdEncoding.EncodeToString([]byte(token)) - header := fmt.Sprintf("Authorization: Basic %s", encodedToken) + header := basicAuthHeader(c.username, c.password) authArgs = append(authArgs, "-c", fmt.Sprintf("http.extraHeader=%s", header)) } @@ -241,6 +247,14 @@ func (c *client) Clone(ctx context.Context, repoID, remote, branch, destination return nil, fmt.Errorf("failed to set user: %v", err) } } + // Persist the basic auth header into the checked-out repo's git config so that + // subsequent git operations (e.g. pull, fetch, push) run directly against the + // remote continue to be authenticated, not just this initial clone. + if c.username != "" && c.password != "" { + if err := r.setHTTPAuthHeader(ctx, basicAuthHeader(c.username, c.password)); err != nil { + return nil, fmt.Errorf("failed to set up http auth: %v", err) + } + } logger.Info("setting gc.autoDetach", zap.Bool("gc.autoDetach", c.gcAutoDetach)) if err := r.setGCAutoDetach(ctx, c.gcAutoDetach); err != nil { diff --git a/pkg/git/client_test.go b/pkg/git/client_test.go index 036c85d047..28f9a3ad54 100644 --- a/pkg/git/client_test.go +++ b/pkg/git/client_test.go @@ -20,6 +20,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "testing" "time" @@ -92,6 +93,43 @@ func TestClone(t *testing.T) { assert.Equal(t, "Added note.txt", commits12[0].Message) } +func TestClonePersistsHTTPAuthHeader(t *testing.T) { + faker, err := newFaker() + require.NoError(t, err) + defer faker.clean() + + c, err := NewClient(WithUserName("git-user"), WithPassword("git-pass")) + require.NoError(t, err) + require.NotNil(t, c) + defer c.Clean() + + err = faker.makeRepo("test-clone-org", "repo-auth") + require.NoError(t, err) + + ctx := context.Background() + destPath, err := os.MkdirTemp("", "repo-auth-dest") + require.NoError(t, err) + + repo, err := c.Clone(ctx, "repo-auth", filepath.Join(faker.dir, "test-clone-org/repo-auth"), "", destPath) + require.NoError(t, err) + require.NotNil(t, repo) + defer func() { + assert.NoError(t, repo.Clean()) + }() + + // Simulates what happens after the initial clone: a plain `git pull` run + // directly against the checked-out repo, as done by e.g. the event watcher. + // This must be able to find the credentials without any extra args, otherwise + // it fails with "could not read Username" for an HTTP(S) remote requiring auth. + cmd := exec.CommandContext(ctx, c.(*client).gitPath, "config", "--get", "http.extraHeader") + cmd.Dir = destPath + out, err := cmd.CombinedOutput() + require.NoError(t, err, string(out)) + + wantHeader := basicAuthHeader("git-user", "git-pass") + assert.Equal(t, wantHeader, strings.TrimSuffix(string(out), "\n")) +} + type faker struct { dir string gitPath string diff --git a/pkg/git/repo.go b/pkg/git/repo.go index a4b510c195..284378c17e 100644 --- a/pkg/git/repo.go +++ b/pkg/git/repo.go @@ -71,6 +71,11 @@ type repo struct { username string email string + // httpAuthHeader is the HTTP "Authorization" header value used to authenticate + // with the remote when it's accessed over HTTP(S). It's empty when username/password + // based authentication isn't configured. It's set in the `setHTTPAuthHeader` method. + httpAuthHeader string + dir string gitPath string remote string @@ -208,6 +213,14 @@ func (r *repo) CopyToModify(dest string) (Repo, error) { } } + // the cloned repo doesn't inherit custom config from the source one, + // so we need to set the http auth header again if it was configured. + if r.httpAuthHeader != "" { + if err := cloned.setHTTPAuthHeader(context.Background(), r.httpAuthHeader); err != nil { + return nil, fmt.Errorf("failed to set up http auth: %v", err) + } + } + // because we did a local cloning so set the remote url of origin if err := cloned.setRemote(context.Background(), r.remote); err != nil { return nil, err @@ -462,6 +475,18 @@ func (r *repo) setUser(ctx context.Context, username, email string) error { return nil } +// setHTTPAuthHeader persists the given HTTP "Authorization" header value into the +// repo's git config, so that it's automatically included by git in every future +// git command run against this repo directory, not just the command it was +// originally issued with via a one-off `-c` flag. +func (r *repo) setHTTPAuthHeader(ctx context.Context, header string) error { + if out, err := r.runGitCommand(ctx, "config", "http.extraHeader", header); err != nil { + return formatCommandError(err, out) + } + r.httpAuthHeader = header + return nil +} + func (r *repo) setRemote(ctx context.Context, remote string) error { out, err := r.runGitCommand(ctx, "remote", "set-url", "origin", remote) if err != nil { diff --git a/pkg/git/repo_test.go b/pkg/git/repo_test.go index d36cb0c3f9..b9445be404 100644 --- a/pkg/git/repo_test.go +++ b/pkg/git/repo_test.go @@ -243,6 +243,49 @@ func Test_setGCAutoDetach(t *testing.T) { assert.Equal(t, false, got) } +func Test_setHTTPAuthHeader(t *testing.T) { + getHTTPExtraHeader := func(ctx context.Context, repo *repo) (string, error) { + cmd := exec.CommandContext(ctx, repo.gitPath, "config", "--get", "http.extraHeader") + cmd.Dir = repo.dir + out, err := cmd.CombinedOutput() + if err != nil { + return "", err + } + return strings.TrimSuffix(string(out), "\n"), nil + } + + faker, err := newFaker() + require.NoError(t, err) + defer faker.clean() + + var ( + org = "test-repo-org" + repoName = "repo-set-http-auth-header" + ctx = context.Background() + ) + + err = faker.makeRepo(org, repoName) + require.NoError(t, err) + + r := &repo{ + dir: faker.repoDir(org, repoName), + gitPath: faker.gitPath, + } + + // Before being set, the repo has no http.extraHeader configured. + _, err = getHTTPExtraHeader(ctx, r) + require.Error(t, err) + + header := "Authorization: Basic dXNlcjpwYXNz" + err = r.setHTTPAuthHeader(ctx, header) + require.NoError(t, err) + assert.Equal(t, header, r.httpAuthHeader) + + got, err := getHTTPExtraHeader(ctx, r) + require.NoError(t, err) + assert.Equal(t, header, got) +} + func TestCopy(t *testing.T) { faker, err := newFaker() require.NoError(t, err) @@ -293,6 +336,10 @@ func TestCopyToModify(t *testing.T) { remote: faker.repoDir(org, repoName), // use the same directory as remote, it's not a real remote. it's strange but it's ok for testing. } + header := "Authorization: Basic dXNlcjpwYXNz" + err = r.setHTTPAuthHeader(ctx, header) + require.NoError(t, err) + commits, err := r.ListCommits(ctx, "") require.NoError(t, err) assert.Equal(t, 1, len(commits)) @@ -301,6 +348,15 @@ func TestCopyToModify(t *testing.T) { newRepo, err := r.CopyToModify(tmpDir) require.NoError(t, err) + // The http auth header must be propagated to the cloned repo too, + // since it's not carried over by a plain `git clone`. + assert.Equal(t, header, newRepo.(*repo).httpAuthHeader) + cmd := exec.CommandContext(ctx, r.gitPath, "config", "--get", "http.extraHeader") + cmd.Dir = tmpDir + out, err := cmd.CombinedOutput() + require.NoError(t, err) + assert.Equal(t, header, strings.TrimSuffix(string(out), "\n")) + // we can copy the repo to another directory multiple times tmpDir2 := filepath.Join(faker.dir, "tmp-repo2") newRepo2, err := r.CopyToModify(tmpDir2) From 9ab1433767f69c2dbd6f2faea0bc00256a817a43 Mon Sep 17 00:00:00 2001 From: Pujitha Paladugu <10557236+pujitha24@users.noreply.github.com> Date: Sat, 22 Aug 2026 03:56:41 -0700 Subject: [PATCH 2/2] Use --local and --replace-all when persisting git http.extraHeader Signed-off-by: Pujitha Paladugu <10557236+pujitha24@users.noreply.github.com> --- pkg/git/repo.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/git/repo.go b/pkg/git/repo.go index 284378c17e..9772d242c2 100644 --- a/pkg/git/repo.go +++ b/pkg/git/repo.go @@ -480,7 +480,7 @@ func (r *repo) setUser(ctx context.Context, username, email string) error { // git command run against this repo directory, not just the command it was // originally issued with via a one-off `-c` flag. func (r *repo) setHTTPAuthHeader(ctx context.Context, header string) error { - if out, err := r.runGitCommand(ctx, "config", "http.extraHeader", header); err != nil { + if out, err := r.runGitCommand(ctx, "config", "--local", "--replace-all", "http.extraHeader", header); err != nil { return formatCommandError(err, out) } r.httpAuthHeader = header