From 0dce3188d096ad0ff6a2d4660fa090903f58fcfd Mon Sep 17 00:00:00 2001 From: Sergiy Kulanov Date: Sat, 24 Oct 2026 12:20:05 +0300 Subject: [PATCH] EPMDEDP-17426: feat: Use git CLI for clone, push, checkout and commit go-git holds packfiles in operator memory. Large repositories exceed the memory limit. - Run Clone, Push, Commit, Checkout, CheckoutRemoteBranch and CreateChildBranch through the git binary; keep go-git for ref operations, init, remote link and ls-remote - Auth: token through scoped GIT_CONFIG_* headers, SSH key in /dev/shm, mandatory host key checks against SSH_KNOWN_HOSTS, Go CA roots, GIT_ALLOW_PROTOCOL=http:https:ssh - Bound hangs: HTTP and SSH timeouts, process-group kill on cancel, stale .lock removal, capped output - Clone creates local branches; a failed clone removes the destination - Bump base image to alpine:3.22.7 with pinned git, openssh-client and ca-certificates; copy templates and configs as 0755 Signed-off-by: Sergiy Kulanov --- CLAUDE.md | 2 +- Dockerfile | 12 +- .../codebase/service/chain/put_project.go | 17 +- deploy-templates/Chart.yaml | 4 +- pkg/git/checkout_remote_test.go | 52 +- pkg/git/cli.go | 994 ++++++++++++++++++ pkg/git/cli_behavior_test.go | 665 ++++++++++++ pkg/git/cli_ca_test.go | 200 ++++ pkg/git/cli_test.go | 465 ++++++++ pkg/git/factory.go | 4 +- pkg/git/provider.go | 351 +------ pkg/git/provider_test.go | 178 ++-- pkg/sshhostkey/hostkey.go | 15 +- 13 files changed, 2520 insertions(+), 439 deletions(-) create mode 100644 pkg/git/cli.go create mode 100644 pkg/git/cli_behavior_test.go create mode 100644 pkg/git/cli_ca_test.go create mode 100644 pkg/git/cli_test.go diff --git a/CLAUDE.md b/CLAUDE.md index ce2b18d5..578122a9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,7 +60,7 @@ pkg/gitprovider/ — Per-provider adapters: github.go, gitlab.go, bitb Factory: NewProvider() dispatches on GitServer.Spec.GitProvider pkg/client/jira/ — Jira REST client (go-jira wrapper + mockable Client interface) pkg/gerrit/ — SSH-based Gerrit client -pkg/git/ — go-git wrapper + factory (supports SSH/HTTPS auth) +pkg/git/ — Git provider + factory (supports SSH/HTTPS auth): clone, push, fetch, checkout, commit run the git binary; ref, init and packless remote operations use go-git pkg/webhook/ — Admission webhooks: Codebase and CodebaseBranch validation pkg/objectmodifier/ — Applies default values / label patches before reconcile logic pkg/util/ — Shared helpers (workdir, timeouts, URL utilities, templates) diff --git a/Dockerfile b/Dockerfile index 40858f09..5f89b20b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM alpine:3.18.12 +FROM alpine:3.22.7 ARG TARGETARCH @@ -9,15 +9,15 @@ ENV ASSETS_DIR=/usr/local/bin \ USER_NAME=codebase-operator \ USER_UID=1001 -RUN apk add --no-cache ca-certificates=20241121-r1 \ - openssh-client==9.3_p2-r3 \ - git==2.40.4-r0 +RUN apk add --no-cache ca-certificates=20261020-r0 \ + openssh-client==10.0_p1-r10 \ + git==2.49.1-r0 RUN adduser -h ${HOME} -s /bin/ash -D -u ${USER_UID} codebase-operator COPY build/bin ${ASSETS_DIR} -COPY --chmod=0775 build/templates ${ASSETS_DIR}/templates -COPY build/configs ${ASSETS_DIR}/configs +COPY --chmod=0755 build/templates ${ASSETS_DIR}/templates +COPY --chmod=0755 build/configs ${ASSETS_DIR}/configs RUN chmod u+x ${ASSETS_DIR}/user_setup && \ chmod ugo+x ${ASSETS_DIR}/entrypoint && \ diff --git a/controllers/codebase/service/chain/put_project.go b/controllers/codebase/service/chain/put_project.go index 7bdaf242..0cde2a31 100644 --- a/controllers/codebase/service/chain/put_project.go +++ b/controllers/codebase/service/chain/put_project.go @@ -155,22 +155,19 @@ func (h *PutProject) createProject( } } - // The remote branch, not local state, is the durable record of a push: a - // retry after any later failure (default-branch call, status patch, - // operator crash) regenerates history, and pushing it would silently - // replace the remote branch. Record intent only when the remote default - // branch is verified absent, so that on retry "in progress + branch - // present" can only mean our own push landed. + // The remote branch, not local state, records a push. A retry after a later + // failure (default-branch call, status patch, operator crash) regenerates + // local history. Record intent only when the remote default branch is + // absent: on retry, "in progress + branch present" then means this + // provisioning pushed it. remoteBranchAbsent, err := h.remoteDefaultBranchAbsent(ctx, codebase, repoContext) if err != nil { return err } if !remoteBranchAbsent { - // go-git cannot verify fast-forward when the remote's current commit - // is absent from the freshly regenerated local history, so this push - // would silently replace the remote branch. Refuse instead of - // destroying history that this codebase provably did not just push. + // The remote default branch holds history this provisioning did not push; + // the regenerated local history does not contain it. Refuse to push. return fmt.Errorf( "remote repository already contains default branch %s with history not pushed by this provisioning; "+ "refusing to overwrite it - remove the remote branch or onboard the repository with the import strategy", diff --git a/deploy-templates/Chart.yaml b/deploy-templates/Chart.yaml index 45358132..76a3bc41 100644 --- a/deploy-templates/Chart.yaml +++ b/deploy-templates/Chart.yaml @@ -35,8 +35,8 @@ annotations: artifacthub.io/images: | - name: codebase-operator:2.36.0 image: epamedp/codebase-operator:2.36.0 - - name: alpine:3.18.12 - image: alpine:3.18.12 + - name: alpine:3.22.7 + image: alpine:3.22.7 artifacthub.io/operatorCapabilities: Full Lifecycle artifacthub.io/crds: | - kind: CDStageDeploy diff --git a/pkg/git/checkout_remote_test.go b/pkg/git/checkout_remote_test.go index 6c0e71fe..e2ef5662 100644 --- a/pkg/git/checkout_remote_test.go +++ b/pkg/git/checkout_remote_test.go @@ -55,7 +55,7 @@ func buildOriginWithClone(t *testing.T) (string, string) { seedCommit(t, originDir, "base", "+refs/heads/master:refs/heads/master") workDir := path.Join(t.TempDir(), "clone") - gp := NewGitProvider(Config{}) + gp := NewCLIGitProvider(Config{}) require.NoError(t, gp.Clone(context.Background(), originDir, workDir)) return originDir, workDir @@ -74,7 +74,7 @@ func workdirHead(t *testing.T, workDir string) *plumbing.Reference { } // A branch pushed to origin after the workdir was cloned is fetched into -// refs/heads by Checkout's refs/*:refs/* refspec; the existence check must look +// refs/heads by Checkout's branch refspec; the existence check must look // there, or the checkout collides on Create with the ref the fetch just wrote. func TestCheckout_RemoteBranchCreatedAfterClone(t *testing.T) { originDir, workDir := buildOriginWithClone(t) @@ -88,7 +88,7 @@ func TestCheckout_RemoteBranchCreatedAfterClone(t *testing.T) { plumbing.NewHashReference(plumbing.NewBranchReferenceName("late-branch"), masterRef.Hash()), )) - gp := NewGitProvider(Config{}) + gp := NewCLIGitProvider(Config{}) err = gp.Checkout(context.Background(), workDir, "late-branch", true) require.NoError(t, err, "checkout of a branch created after clone must succeed") @@ -106,7 +106,7 @@ func TestCheckout_RemoteBranchPresentAtCloneTime(t *testing.T) { seedCommit(t, originDir, "base", "+refs/heads/master:refs/heads/feature") workDir := path.Join(t.TempDir(), "clone") - gp := NewGitProvider(Config{}) + gp := NewCLIGitProvider(Config{}) require.NoError(t, gp.Clone(context.Background(), originDir, workDir)) require.NoError(t, gp.Checkout(context.Background(), workDir, "feature", true)) @@ -118,7 +118,7 @@ func TestCheckout_RemoteBranchPresentAtCloneTime(t *testing.T) { func TestCheckout_BranchAbsentEverywhere(t *testing.T) { _, workDir := buildOriginWithClone(t) - gp := NewGitProvider(Config{}) + gp := NewCLIGitProvider(Config{}) require.NoError(t, gp.Checkout(context.Background(), workDir, "brand-new", true)) require.Equal(t, "brand-new", workdirHead(t, workDir).Name().Short()) } @@ -130,8 +130,48 @@ func TestCheckout_ForcePushedRemoteBranch(t *testing.T) { rewritten := seedCommit(t, originDir, "rewritten-history", "+refs/heads/master:refs/heads/master") - gp := NewGitProvider(Config{}) + gp := NewCLIGitProvider(Config{}) require.NoError(t, gp.Checkout(context.Background(), workDir, "master", true), "force-pushed upstream branch must not fail the checkout fetch") require.Equal(t, rewritten, workdirHead(t, workDir).Hash()) } + +// TestFetch_SkipsNonBranchRefs: refs outside refs/heads and refs/tags, such as +// pull request heads, are not fetched, so their objects are not downloaded. +func TestFetch_SkipsNonBranchRefs(t *testing.T) { + tests := []struct { + name string + checkout func(gp *CLIGitProvider, workDir string) error + }{ + { + name: "Checkout remote", + checkout: func(gp *CLIGitProvider, workDir string) error { + return gp.Checkout(context.Background(), workDir, "master", true) + }, + }, + { + name: "CheckoutRemoteBranch", + checkout: func(gp *CLIGitProvider, workDir string) error { + return gp.CheckoutRemoteBranch(context.Background(), workDir, "master") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + originDir, workDir := buildOriginWithClone(t) + prHead := seedCommit(t, originDir, "unmerged", "+refs/heads/master:refs/pull/1/head") + + require.NoError(t, tt.checkout(NewCLIGitProvider(Config{}), workDir)) + + repo, err := gogit.PlainOpen(workDir) + require.NoError(t, err) + + _, err = repo.Reference("refs/pull/1/head", false) + require.ErrorIs(t, err, plumbing.ErrReferenceNotFound) + + _, err = repo.CommitObject(prHead) + require.ErrorIs(t, err, plumbing.ErrObjectNotFound) + }) + } +} diff --git a/pkg/git/cli.go b/pkg/git/cli.go new file mode 100644 index 00000000..d47b90fc --- /dev/null +++ b/pkg/git/cli.go @@ -0,0 +1,994 @@ +package v2 + +import ( + "bytes" + "context" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "errors" + "fmt" + "net/url" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync/atomic" + "syscall" + "time" + + "github.com/go-git/go-git/v5/plumbing/transport" + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/knownhosts" + ctrl "sigs.k8s.io/controller-runtime" + + "github.com/epam/edp-codebase-operator/v2/pkg/sshhostkey" +) + +const ( + maxCLIOutput = 4096 + // maxCapture is the most stdout and the most stderr bytes kept per git call. + maxCapture = 1 << 20 + + // OpenSSH client stderr text, matched by substring. Re-check on openssh-client upgrades. + hostKeyFailedMsg = "Host key verification failed" + hostKeyChangedMsg = "REMOTE HOST IDENTIFICATION HAS CHANGED" + hostKeyRevokedMsg = "REVOKED HOST KEY DETECTED" + + // Network stall bounds for remote operations, in seconds. + httpStallSeconds = 600 + sshConnectSecs = 30 + sshAliveSecs = 15 + sshAliveMax = 4 +) + +var ( + // terminateGrace is the time git and its child processes get after SIGTERM before SIGKILL. + terminateGrace = 10 * time.Second + // pipeDrainGrace is the time exec waits for the output pipes after the kill. + pipeDrainGrace = 2 * time.Second +) + +// Copy of certFiles and certDirectories in crypto/x509 root_linux.go. Re-sync on Go upgrades. +// Only the first readable file is used. +var ( + caFiles = []string{ + "/etc/ssl/certs/ca-certificates.crt", + "/etc/pki/tls/certs/ca-bundle.crt", + "/etc/ssl/ca-bundle.pem", + "/etc/pki/tls/cacert.pem", + "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", + "/etc/ssl/cert.pem", + } + caDirs = []string{"/etc/ssl/certs", "/etc/pki/tls/certs"} +) + +// proxyEnv lists the proxy variables Go reads. Go prefers the uppercase name; libcurl in git +// ignores uppercase HTTP_PROXY. baseEnv passes the Go-resolved value under both names. +var proxyEnv = []string{"HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY"} + +// gitConfig is one entry of the GIT_CONFIG_COUNT/KEY_n/VALUE_n environment configuration. +type gitConfig struct{ key, value string } + +// baseConfig applies to every git invocation. +// - gc.auto=0, maintenance.auto=false: no detached repack that outlives the call. +// - pack.threads=1: one pack thread. +var baseConfig = []gitConfig{ + {"gc.auto", "0"}, + {"maintenance.auto", "false"}, + {"pack.threads", "1"}, +} + +// defaultAllowedProtocols is GIT_ALLOW_PROTOCOL for remote operations. +const defaultAllowedProtocols = "http:https:ssh" + +var allowedProtocols = defaultAllowedProtocols + +// remoteConfig applies to git invocations that contact a remote. +// http.lowSpeedLimit/lowSpeedTime abort a transfer that moves under 1 byte/s for httpStallSeconds. +var remoteConfig = []gitConfig{ + {"http.lowSpeedLimit", "1"}, + {"http.lowSpeedTime", strconv.Itoa(httpStallSeconds)}, +} + +var _ Git = (*CLIGitProvider)(nil) + +// CLIGitProvider runs the operations that read or write packfiles or the work tree through +// the git binary: Clone, Push, Commit, Checkout, CheckoutRemoteBranch, CreateChildBranch. +// go-git holds that data in the operator process. All other operations stay on go-git. +type CLIGitProvider struct { + *GitProvider +} + +// NewCLIGitProvider creates a CLIGitProvider with the given configuration. +func NewCLIGitProvider(cfg Config) *CLIGitProvider { + return &CLIGitProvider{GitProvider: NewGitProvider(cfg)} +} + +// Clone checks out the remote default branch and stores all remote branches as local +// branches and all tags. The work tree matches the cloned default branch. +// - Fails with transport.ErrEmptyRemoteRepository if the remote has no branches, and +// with an error if the remote HEAD does not resolve. +// - A failed clone, branch creation or HEAD check removes destination, unless destination held files +// before the call. git refuses to clone into a non-empty directory. An empty directory that existed +// before the call is emptied and kept. +func (p *CLIGitProvider) Clone(ctx context.Context, repoURL, destination string) error { + log := ctrl.LoggerFrom(ctx).WithValues("repository", repoURL, "destination", destination) + log.Info("Cloning repository with git CLI") + + state, err := inspectDestination(destination) + if err != nil { + return fmt.Errorf("failed to inspect clone destination: %w", err) + } + + if err = p.runRemote(ctx, repoURL, "clone", "--", repoURL, destination); err != nil { + return cleanupClone(destination, state, + fmt.Errorf("failed to clone repository: %w", remoteErr(err, repoURL))) + } + + if err = p.requireHead(ctx, destination); err != nil { + return cleanupClone(destination, state, fmt.Errorf("failed to clone repository: %w", err)) + } + + log.Info("Repository cloned successfully, now creating local branches") + + err = p.runLocal(ctx, "-C", destination, "fetch", "--quiet", "--update-head-ok", ".", + "+refs/remotes/origin/*:refs/heads/*", "^refs/remotes/origin/HEAD") + if err != nil { + return cleanupClone(destination, state, fmt.Errorf("failed to create local branches: %w", err)) + } + + log.Info("Local branches created successfully") + + return nil +} + +// requireHead fails if HEAD of the clone does not resolve to a commit. +func (p *CLIGitProvider) requireHead(ctx context.Context, directory string) error { + err := p.runLocal(ctx, "-C", directory, "rev-parse", "--verify", "--quiet", "HEAD") + if err == nil { + return nil + } + + if !hasExitCode(err, 1) { + return fmt.Errorf("failed to resolve HEAD: %w", err) + } + + refs, err := p.outputLocal(ctx, "-C", directory, "for-each-ref", "--count=1", + "--format=%(refname)", "refs/remotes/origin") + if err != nil { + return fmt.Errorf("failed to list remote branches: %w", err) + } + + if strings.TrimSpace(refs) == "" { + return transport.ErrEmptyRemoteRepository + } + + return errors.New("remote HEAD does not point to an existing branch") +} + +type destinationState int + +const ( + destinationAbsent destinationState = iota + destinationEmpty + destinationOccupied +) + +// inspectDestination reports whether path is absent, an empty directory, or a file or a +// non-empty directory. +func inspectDestination(path string) (destinationState, error) { + info, err := os.Stat(path) + if errors.Is(err, os.ErrNotExist) { + return destinationAbsent, nil + } + + if err != nil { + return destinationAbsent, err + } + + if !info.IsDir() { + return destinationOccupied, nil + } + + entries, err := os.ReadDir(path) + if err != nil { + return destinationAbsent, err + } + + if len(entries) > 0 { + return destinationOccupied, nil + } + + return destinationEmpty, nil +} + +// cleanupClone restores destination to its state before the clone and returns cause. +// An occupied destination stays untouched; an empty directory is emptied and kept. +func cleanupClone(destination string, state destinationState, cause error) error { + var rmErr error + + switch state { + case destinationOccupied: + return cause + case destinationEmpty: + rmErr = emptyDirectory(destination) + case destinationAbsent: + rmErr = os.RemoveAll(destination) + } + + if rmErr != nil { + return errors.Join(cause, fmt.Errorf("failed to remove %s: %w", destination, rmErr)) + } + + return cause +} + +func emptyDirectory(path string) error { + entries, err := os.ReadDir(path) + if err != nil { + return err + } + + for _, e := range entries { + if err = os.RemoveAll(filepath.Join(path, e.Name())); err != nil { + return err + } + } + + return nil +} + +// Push pushes refspecs to origin without forcing. Without refspecs it pushes all branches. +func (p *CLIGitProvider) Push(ctx context.Context, directory string, refspecs ...string) error { + log := ctrl.LoggerFrom(ctx).WithValues("directory", directory) + log.Info("Pushing changes with git CLI") + + if len(refspecs) == 0 { + refspecs = []string{RefSpecPushAllBranches} + } + + origin, err := p.originURL(ctx, directory) + if err != nil { + return fmt.Errorf("failed to push: %w", err) + } + + args := append([]string{"-C", directory, "push", "--", "origin"}, refspecs...) + + if err = p.runRemote(ctx, origin, args...); err != nil { + return fmt.Errorf("failed to push: %w", remoteErr(err, origin)) + } + + log.Info("Changes pushed successfully") + + return nil +} + +// Commit stages all changes, including deletions, and commits them as defaultCommitName. +// Without CommitAllowEmpty, a clean work tree is not an error and creates no commit. +// Hooks and commit signing are disabled. +func (p *CLIGitProvider) Commit(ctx context.Context, directory, message string, ops ...CommitOps) error { + log := ctrl.LoggerFrom(ctx).WithValues("directory", directory) + log.Info("Committing changes with git CLI") + + option := &commitOps{} + for _, applyOption := range ops { + applyOption(option) + } + + if err := p.runLocal(ctx, "-C", directory, "add", "--all"); err != nil { + return fmt.Errorf("failed to add files to index: %w", err) + } + + if !option.allowEmptyCommit { + err := p.runLocal(ctx, "-C", directory, "diff", "--cached", "--quiet") + if err == nil { + log.Info("Nothing to commit, working tree clean") + return nil + } + + if !hasExitCode(err, 1) { + return fmt.Errorf("failed to get status: %w", err) + } + } + + args := []string{ + "-C", directory, + "-c", "core.hooksPath=/dev/null", + "-c", "commit.gpgsign=false", + "commit", "--quiet", "--allow-empty-message", "--message", message, + } + if option.allowEmptyCommit { + args = append(args, "--allow-empty") + } + + if err := p.runLocal(ctx, args...); err != nil { + return fmt.Errorf("failed to commit: %w", err) + } + + log.Info("Changes committed successfully") + + return nil +} + +// Checkout force-checks out branchName. +// - remote=false: creates the branch from HEAD; fails if it exists. +// - remote=true: fetches branches and tags from origin first; creates the branch from HEAD +// only if it does not exist after the fetch. +func (p *CLIGitProvider) Checkout(ctx context.Context, directory, branchName string, remote bool) error { + log := ctrl.LoggerFrom(ctx).WithValues("directory", directory, "branch", branchName, "remote", remote) + log.Info("Checking out branch with git CLI") + + create := true + + if remote { + if err := p.fetchOrigin(ctx, directory); err != nil { + return err + } + + exists, err := p.branchExists(ctx, directory, branchName) + if err != nil { + return err + } + + create = !exists + } + + if err := p.switchBranch(ctx, directory, branchName, create); err != nil { + return fmt.Errorf("failed to checkout branch: %w", err) + } + + log.Info("Branch checked out successfully") + + return nil +} + +// CheckoutRemoteBranch fetches branches and tags from origin and force-checks out branchName. +// Fails if the branch does not exist after the fetch. +func (p *CLIGitProvider) CheckoutRemoteBranch(ctx context.Context, directory, branchName string) error { + log := ctrl.LoggerFrom(ctx).WithValues("directory", directory, "branch", branchName) + log.Info("Checking out remote branch with git CLI") + + if err := p.fetchOrigin(ctx, directory); err != nil { + return err + } + + if err := p.switchBranch(ctx, directory, branchName, false); err != nil { + return fmt.Errorf("failed to checkout branch: %w", err) + } + + log.Info("Remote branch checked out successfully") + + return nil +} + +// CreateChildBranch force-checks out parentBranch and creates newBranch from it. +func (p *CLIGitProvider) CreateChildBranch(ctx context.Context, directory, parentBranch, newBranch string) error { + log := ctrl.LoggerFrom(ctx).WithValues("directory", directory, "parent", parentBranch, "newBranch", newBranch) + log.Info("Creating child branch with git CLI") + + if err := p.switchBranch(ctx, directory, parentBranch, false); err != nil { + return fmt.Errorf("failed to checkout parent branch: %w", err) + } + + if err := p.switchBranch(ctx, directory, newBranch, true); err != nil { + return fmt.Errorf("failed to create child branch: %w", err) + } + + log.Info("Child branch created successfully") + + return nil +} + +// fetchOrigin updates local branches and tags from origin, including the checked-out branch. +func (p *CLIGitProvider) fetchOrigin(ctx context.Context, directory string) error { + origin, err := p.originURL(ctx, directory) + if err != nil { + return fmt.Errorf("failed to fetch: %w", err) + } + + err = p.runRemote(ctx, origin, "-C", directory, "fetch", "--quiet", "origin", + refSpecFetchBranches, refSpecFetchTags, "--update-head-ok") + if err != nil { + return fmt.Errorf("failed to fetch: %w", remoteErr(err, origin)) + } + + return nil +} + +// switchBranch force-switches to branchName, or creates it from HEAD when create is set. +// Only refs/heads/ matches: no tag, no remote-tracking branch guess, and a name +// that starts with "-" is never read as an option. +func (p *CLIGitProvider) switchBranch(ctx context.Context, directory, branchName string, create bool) error { + args := []string{"-C", directory, "switch", "--force"} + if create { + args = append(args, "--create", branchName) + } else { + args = append(args, "--no-guess", "--end-of-options", branchName) + } + + return p.runLocal(ctx, args...) +} + +func (p *CLIGitProvider) branchExists(ctx context.Context, directory, branchName string) (bool, error) { + err := p.runLocal(ctx, "-C", directory, "show-ref", "--verify", "--quiet", "refs/heads/"+branchName) + if err == nil { + return true, nil + } + + if hasExitCode(err, 1) { + return false, nil + } + + return false, fmt.Errorf("failed to look up branch %s: %w", branchName, err) +} + +func (p *CLIGitProvider) originURL(ctx context.Context, directory string) (string, error) { + out, err := p.outputLocal(ctx, "-C", directory, "remote", "get-url", "origin") + if err != nil { + return "", fmt.Errorf("failed to read origin URL: %w", err) + } + + return strings.TrimSpace(out), nil +} + +// runRemote executes git with the provider's auth and returns an error carrying redacted output. +// remoteURL scopes the token to the remote host. Callers wrap the error with remoteErr. +func (p *CLIGitProvider) runRemote(ctx context.Context, remoteURL string, args ...string) error { + env, cleanup, err := p.buildEnv(remoteURL) + if err != nil { + return fmt.Errorf("failed to get authentication: %w", err) + } + defer cleanup() + + _, err = p.exec(ctx, env, args...) + + return err +} + +// runLocal executes git without credentials, for operations that do not contact a remote. +func (p *CLIGitProvider) runLocal(ctx context.Context, args ...string) error { + _, err := p.exec(ctx, gitEnv(), args...) + + return err +} + +func (p *CLIGitProvider) outputLocal(ctx context.Context, args ...string) (string, error) { + return p.exec(ctx, gitEnv(), args...) +} + +// exec returns stdout; on failure the error carries redacted stderr and stdout. +// +// git runs in its own process group. On context cancellation the group gets SIGTERM, so git +// removes its lock files, and SIGKILL after terminateGrace. After a SIGKILL the lock files in +// the -C repository are removed; the same applies to a git process that a signal ended. +// The error then wraps ctx.Err(). +// +// When git ends with an error, or exits 0 while a child holds the output pipes, the process +// group gets SIGKILL before exec returns. +func (p *CLIGitProvider) exec(ctx context.Context, env []string, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, "git", args...) + cmd.Env = withCeiling(env, args) + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + + grace := terminateGrace + cmd.WaitDelay = grace + pipeDrainGrace + + done := make(chan struct{}) + defer close(done) + + var killed atomic.Bool + + cmd.Cancel = func() error { + pgid := cmd.Process.Pid + + go func() { + select { + case <-done: + case <-time.After(grace): + killed.Store(true) + + _ = syscall.Kill(-pgid, syscall.SIGKILL) + } + }() + + return syscall.Kill(-pgid, syscall.SIGTERM) + } + + stdout, stderr := &tailBuffer{limit: maxCapture}, &tailBuffer{limit: maxCapture} + + cmd.Stdout = stdout + cmd.Stderr = stderr + + err := cmd.Run() + + lingering := errors.Is(err, exec.ErrWaitDelay) && ctx.Err() == nil && cmd.ProcessState.Success() + if lingering { + err = nil + } + + if (err != nil || lingering) && cmd.Process != nil { + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + } + + if err != nil { + if killed.Load() || diedBySignal(err) { + removeLocks(args) + } + + err = cliError(err, p.redact(stderr.String()+stdout.String())) + if ctxErr := ctx.Err(); ctxErr != nil { + err = fmt.Errorf("%w: %w", ctxErr, err) + } + + return "", err + } + + return stdout.String(), nil +} + +// hasExitCode reports whether err comes from a git process that exited with code. +func hasExitCode(err error, code int) bool { + var exitErr *exec.ExitError + + return errors.As(err, &exitErr) && exitErr.ExitCode() == code +} + +// tailBuffer keeps the last limit bytes written to it. +type tailBuffer struct { + limit int + data []byte +} + +func (b *tailBuffer) Write(p []byte) (int, error) { + b.data = append(b.data, p...) + if len(b.data) > 2*b.limit { + b.data = append(b.data[:0], b.data[len(b.data)-b.limit:]...) + } + + return len(p), nil +} + +func (b *tailBuffer) String() string { + if len(b.data) > b.limit { + return string(b.data[len(b.data)-b.limit:]) + } + + return string(b.data) +} + +// diedBySignal reports whether err comes from a git process that a signal ended. +func diedBySignal(err error) bool { + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) { + return false + } + + status, ok := exitErr.Sys().(syscall.WaitStatus) + + return ok && status.Signaled() +} + +// repoDir returns the directory named by a -C argument that precedes the git subcommand, or "". +func repoDir(args []string) string { + for i := 0; i < len(args); i++ { + switch { + case args[i] == "-C" && i+1 < len(args): + return args[i+1] + case args[i] == "-c": + i++ + case !strings.HasPrefix(args[i], "-"): + return "" + } + } + + return "" +} + +// withCeiling stops git from searching parent directories for a repository when the -C +// directory is not one. +func withCeiling(env, args []string) []string { + dir := repoDir(args) + if dir == "" { + return env + } + + abs, err := filepath.Abs(dir) + if err != nil { + return env + } + + return append(env[:len(env):len(env)], "GIT_CEILING_DIRECTORIES="+filepath.Dir(abs)) +} + +// removeLocks deletes the *.lock files of the repository named by a leading -C argument: +// the files in .git and every *.lock under .git/refs. Call it only when no git process of +// that repository runs. +func removeLocks(args []string) { + dir := repoDir(args) + if dir == "" { + return + } + + gitDir := filepath.Join(dir, ".git") + + if entries, err := os.ReadDir(gitDir); err == nil { + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".lock") { + _ = os.Remove(filepath.Join(gitDir, e.Name())) + } + } + } + + _ = filepath.WalkDir(filepath.Join(gitDir, "refs"), func(path string, d os.DirEntry, err error) error { + if err == nil && !d.IsDir() && strings.HasSuffix(path, ".lock") { + _ = os.Remove(path) + } + + return nil + }) +} + +// cliError maps ssh host key failures to knownhosts errors so remoteErr can enrich them. +func cliError(err error, output string) error { + output = truncate(strings.TrimSpace(output), maxCLIOutput) + base := fmt.Errorf("git: %w: %s", err, output) + + if !strings.Contains(output, hostKeyFailedMsg) { + return base + } + + if strings.Contains(output, hostKeyRevokedMsg) { + return fmt.Errorf("%w: %w", &knownhosts.RevokedError{}, base) + } + + keyErr := &knownhosts.KeyError{} + if strings.Contains(output, hostKeyChangedMsg) { + keyErr.Want = []knownhosts.KnownKey{{}} + } + + return fmt.Errorf("%w: %w", keyErr, base) +} + +func truncate(s string, limit int) string { + if len(s) <= limit { + return s + } + + return "..." + s[len(s)-limit:] +} + +func (p *CLIGitProvider) redact(s string) string { + secrets := p.secrets() + if len(secrets) == 0 { + return s + } + + pairs := make([]string, 0, 2*len(secrets)) + for _, secret := range secrets { + pairs = append(pairs, secret, "[REDACTED]") + } + + return strings.NewReplacer(pairs...).Replace(s) +} + +func (p *CLIGitProvider) secrets() []string { + var secrets []string + + if key := strings.TrimSpace(p.config.SSHKey); key != "" { + secrets = append(secrets, key) + } + + if p.config.Token != "" { + secrets = append(secrets, p.config.Token, basicAuth(p.tokenUsername(), p.config.Token)) + } + + return secrets +} + +func basicAuth(user, token string) string { + return base64.StdEncoding.EncodeToString([]byte(user + ":" + token)) +} + +// gitEnv returns the process environment for git with baseConfig and extra as environment config. +func gitEnv(extra ...gitConfig) []string { + env := []string{ + "GIT_TERMINAL_PROMPT=0", + "GIT_CONFIG_NOSYSTEM=1", + "GIT_CONFIG_GLOBAL=" + os.DevNull, + "HOME=" + os.TempDir(), + "TMPDIR=" + os.TempDir(), + "LC_ALL=C", + "GIT_AUTHOR_NAME=" + defaultCommitName, + "GIT_AUTHOR_EMAIL=" + defaultCommitEmail, + "GIT_COMMITTER_NAME=" + defaultCommitName, + "GIT_COMMITTER_EMAIL=" + defaultCommitEmail, + } + + if path, ok := os.LookupEnv("PATH"); ok { + env = append(env, "PATH="+path) + } + + for _, name := range proxyEnv { + lower := strings.ToLower(name) + + value := os.Getenv(name) + if value == "" { + value = os.Getenv(lower) + } + + if value != "" { + env = append(env, name+"="+value, lower+"="+value) + } + } + + settings := append(append([]gitConfig{}, baseConfig...), extra...) + env = append(env, fmt.Sprintf("GIT_CONFIG_COUNT=%d", len(settings))) + + for i, s := range settings { + env = append(env, fmt.Sprintf("GIT_CONFIG_KEY_%d=%s", i, s.key), fmt.Sprintf("GIT_CONFIG_VALUE_%d=%s", i, s.value)) + } + + return env +} + +// buildEnv returns the process environment for git. The SSH key wins over the token. +// An SSH remote gets the SSH command with or without a key. +// The token header applies only to remoteURL with scheme http or https. +// The CA bundle is written only for https remotes. cleanup removes the temporary files +// and must always be called. +func (p *CLIGitProvider) buildEnv(remoteURL string) (env []string, cleanup func(), err error) { + var files []string + + cleanup = func() { + for _, f := range files { + _ = os.Remove(f) + } + } + + defer func() { + if err != nil { + cleanup() + } + }() + + if p.config.SSHKey != "" || isSSHRemote(remoteURL) { + keyFile := "" + + if p.config.SSHKey != "" { + if _, perr := ssh.ParsePrivateKey([]byte(p.config.SSHKey)); perr != nil { + return nil, cleanup, fmt.Errorf("failed to parse SSH private key: %w", perr) + } + + var werr error + + keyFile, werr = writeTempFile("sshkey", normalizeKey(p.config.SSHKey), "/dev/shm", "") + if werr != nil { + return nil, cleanup, fmt.Errorf("failed to write SSH key: %w", werr) + } + + files = append(files, keyFile) + } + + env = append(gitEnv(remoteConfig...), "GIT_ALLOW_PROTOCOL="+allowedProtocols, + "GIT_SSH_COMMAND="+p.sshCommand(keyFile)) + + return env, cleanup, nil + } + + extra := append([]gitConfig{}, remoteConfig...) + + if scope := httpScope(remoteURL); p.config.Token != "" && scope != "" { + extra = append(extra, gitConfig{ + "http." + scope + ".extraHeader", "Authorization: Basic " + basicAuth(p.tokenUsername(), p.config.Token), + }) + } + + env = append(gitEnv(extra...), "GIT_ALLOW_PROTOCOL="+allowedProtocols) + + if !strings.HasPrefix(remoteURL, "https://") { + return env, cleanup, nil + } + + bundle, berr := writeCABundle() + if berr != nil { + return nil, cleanup, berr + } + + if bundle != "" { + files = append(files, bundle) + env = append(env, "GIT_SSL_CAINFO="+bundle) + } + + return env, cleanup, nil +} + +// sshCommand builds GIT_SSH_COMMAND. With a key file, -l overrides the user inside the URL. +// Without one, the URL user applies and ssh offers no identity. +// With SSH_KNOWN_HOSTS set, the files it lists are the only host key source. +func (p *CLIGitProvider) sshCommand(keyFile string) string { + parts := []string{"ssh"} + + if keyFile != "" { + parts = append(parts, + "-i", shellQuote(keyFile), + "-l", shellQuote(p.config.SSHUser), + "-o", "IdentitiesOnly=yes", + ) + } + + parts = append(parts, + "-o", "BatchMode=yes", + "-o", "StrictHostKeyChecking=yes", + "-o", "ConnectTimeout="+strconv.Itoa(sshConnectSecs), + "-o", "ServerAliveInterval="+strconv.Itoa(sshAliveSecs), + "-o", "ServerAliveCountMax="+strconv.Itoa(sshAliveMax), + ) + + if hosts := filepath.SplitList(sshhostkey.KnownHostsFile()); len(hosts) > 0 { + parts = append(parts, + "-o", shellQuote("UserKnownHostsFile="+quoteHostsFiles(hosts)), + "-o", shellQuote("GlobalKnownHostsFile="+os.DevNull), + ) + } + + return strings.Join(append(parts, + "-o", "HostKeyAlgorithms=+ssh-rsa", + "-o", "PubkeyAcceptedAlgorithms=+ssh-rsa", + ), " ") +} + +// quoteHostsFiles formats paths as ssh_config values: each path double-quoted, % escaped. +func quoteHostsFiles(paths []string) string { + quoted := make([]string, len(paths)) + + for i, path := range paths { + path = strings.NewReplacer(`\`, `\\`, `"`, `\"`, "%", "%%").Replace(path) + quoted[i] = `"` + path + `"` + } + + return strings.Join(quoted, " ") +} + +// normalizeKey returns the key with LF line endings and one trailing newline. +// OpenSSH rejects CRLF, which Go's key parser accepts. +func normalizeKey(key string) string { + return strings.TrimSpace(strings.ReplaceAll(key, "\r\n", "\n")) + "\n" +} + +func shellQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + +// isSSHRemote reports whether rawURL uses the ssh transport, as ssh:// or scp-like syntax. +func isSSHRemote(rawURL string) bool { + ep, err := transport.NewEndpoint(rawURL) + + return err == nil && ep.Protocol == "ssh" +} + +// httpScope returns the git config subsection that matches every URL on the host of rawURL, +// or "" when rawURL does not use http or https. +func httpScope(rawURL string) string { + u, err := url.Parse(rawURL) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return "" + } + + return u.Scheme + "://" + u.Host + "/" +} + +// writeTempFile writes content to a new file in the first directory of dirs that accepts it. +// "" is the default temporary directory. +func writeTempFile(pattern, content string, dirs ...string) (string, error) { + if len(dirs) == 0 { + dirs = []string{""} + } + + var ( + f *os.File + err error + ) + + for _, dir := range dirs { + if f, err = os.CreateTemp(dir, pattern); err == nil { + break + } + } + + if err != nil { + return "", err + } + + _, werr := f.WriteString(content) + cerr := f.Close() + + if err := errors.Join(werr, cerr); err != nil { + _ = os.Remove(f.Name()) + + return "", err + } + + return f.Name(), nil +} + +// writeCABundle writes the roots crypto/x509 trusts as one PEM file and returns its path, +// or "" when there are none. +// - File: SSL_CERT_FILE, else the first readable entry of caFiles. +// - Directories: SSL_CERT_DIR, else caDirs; every file, no OpenSSL hashed names required. +// - Blocks that do not parse as X.509 certificates are dropped; duplicates are written once. +func writeCABundle() (string, error) { + files := caFiles + if f := os.Getenv("SSL_CERT_FILE"); f != "" { + files = []string{f} + } + + dirs := caDirs + if d := os.Getenv("SSL_CERT_DIR"); d != "" { + dirs = filepath.SplitList(d) + } + + var ( + bundle bytes.Buffer + seen = make(map[string]struct{}) + ) + + appendCerts := func(data []byte) { + for { + var block *pem.Block + + block, data = pem.Decode(data) + if block == nil { + return + } + + if block.Type != "CERTIFICATE" { + continue + } + + if _, ok := seen[string(block.Bytes)]; ok { + continue + } + + seen[string(block.Bytes)] = struct{}{} + + if _, err := x509.ParseCertificate(block.Bytes); err != nil { + continue + } + + _ = pem.Encode(&bundle, &pem.Block{Type: "CERTIFICATE", Bytes: block.Bytes}) + } + } + + for _, f := range files { + if data, err := os.ReadFile(f); err == nil { + appendCerts(data) + break + } + } + + for _, dir := range dirs { + entries, err := os.ReadDir(dir) + if err != nil { + continue + } + + for _, e := range entries { + if data, err := os.ReadFile(filepath.Join(dir, e.Name())); err == nil { + appendCerts(data) + } + } + } + + if bundle.Len() == 0 { + return "", nil + } + + path, err := writeTempFile("cabundle", bundle.String()) + if err != nil { + return "", fmt.Errorf("failed to write CA bundle: %w", err) + } + + return path, nil +} diff --git a/pkg/git/cli_behavior_test.go b/pkg/git/cli_behavior_test.go new file mode 100644 index 00000000..8d28e74e --- /dev/null +++ b/pkg/git/cli_behavior_test.go @@ -0,0 +1,665 @@ +package v2 + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + "testing" + "time" + + "github.com/go-git/go-git/v5/plumbing/transport" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func init() { + allowedProtocols += ":file" +} + +func requireBinary(t *testing.T, name string) { + t.Helper() + + if _, err := exec.LookPath(name); err != nil { + t.Skipf("%s not on PATH", name) + } +} + +// newBareRemote creates a bare repository whose main branch holds one commit and which has one +// extra branch per name in branches. The returned value is the file:// URL. +func newBareRemote(t *testing.T, branches ...string) (root, bare string) { + t.Helper() + + root = t.TempDir() + seed := filepath.Join(root, "seed") + bare = filepath.Join(root, "remote.git") + + gitRun(t, "init", "-b", "main", seed) + require.NoError(t, os.WriteFile(filepath.Join(seed, "a.txt"), []byte("a"), 0o600)) + gitRun(t, "-C", seed, "add", ".") + gitRun(t, "-C", seed, "commit", "-m", "init") + + for _, b := range branches { + gitRun(t, "-C", seed, "branch", b) + } + + gitRun(t, "clone", "--bare", seed, bare) + + return root, bare +} + +func TestSSHCommand_UserOverridesURLUser(t *testing.T) { + requireBinary(t, "ssh") + + hostsDir := filepath.Join(t.TempDir(), "my certs") + t.Setenv("SSH_KNOWN_HOSTS", filepath.Join(hostsDir, "known_hosts")) + + env, cleanup, err := NewCLIGitProvider(Config{SSHKey: testSSHKey(t), SSHUser: "gerrit"}).buildEnv("") + require.NoError(t, err) + + defer cleanup() + + for _, destination := range []string{"git@example.com", "example.com"} { + script := envMap(env)["GIT_SSH_COMMAND"] + " -G " + destination + out, err := exec.Command("sh", "-c", script).CombinedOutput() //nolint:gosec // test input + require.NoError(t, err, string(out)) + + assert.Contains(t, string(out), "\nuser gerrit\n", "destination %s", destination) + assert.Contains(t, string(out), "\nuserknownhostsfile "+filepath.Join(hostsDir, "known_hosts")+"\n") + assert.Contains(t, string(out), "\nglobalknownhostsfile "+os.DevNull+"\n") + } +} + +func TestCLIGitProvider_Clone_EmptyRemote(t *testing.T) { + requireBinary(t, "git") + + setCADefaults(t, nil, nil) + + root := t.TempDir() + bare := filepath.Join(root, "empty.git") + gitRun(t, "init", "--bare", "-b", "main", bare) + + dst := filepath.Join(root, "work") + err := NewCLIGitProvider(Config{}).Clone(context.Background(), "file://"+bare, dst) + + require.ErrorIs(t, err, transport.ErrEmptyRemoteRepository) + assert.NoDirExists(t, dst) +} + +func TestCLIGitProvider_Clone_RemoteHeadMissing(t *testing.T) { + requireBinary(t, "git") + + setCADefaults(t, nil, nil) + + root, bare := newBareRemote(t, "dev") + gitRun(t, "-C", bare, "symbolic-ref", "HEAD", "refs/heads/gone") + + dst := filepath.Join(root, "work") + err := NewCLIGitProvider(Config{}).Clone(context.Background(), "file://"+bare, dst) + + require.ErrorContains(t, err, "remote HEAD does not point to an existing branch") + assert.NotErrorIs(t, err, transport.ErrEmptyRemoteRepository) + assert.NoDirExists(t, dst) +} + +func TestCLIGitProvider_Clone_KeepsOccupiedDestination(t *testing.T) { + requireBinary(t, "git") + + setCADefaults(t, nil, nil) + + root, bare := newBareRemote(t) + dst := filepath.Join(root, "work") + require.NoError(t, os.MkdirAll(dst, 0o755)) //nolint:gosec // test directory + keep := filepath.Join(dst, "keep.txt") + require.NoError(t, os.WriteFile(keep, []byte("keep"), 0o600)) + + err := NewCLIGitProvider(Config{}).Clone(context.Background(), "file://"+bare, dst) + + require.Error(t, err) + assert.FileExists(t, keep) +} + +func TestCLIGitProvider_Clone_IntoEmptyDirectory(t *testing.T) { + requireBinary(t, "git") + + setCADefaults(t, nil, nil) + + root, bare := newBareRemote(t) + dst := filepath.Join(root, "work") + require.NoError(t, os.MkdirAll(dst, 0o755)) //nolint:gosec // test directory + + require.NoError(t, NewCLIGitProvider(Config{}).Clone(context.Background(), "file://"+bare, dst)) + assert.FileExists(t, filepath.Join(dst, "a.txt")) +} + +func TestCLIGitProvider_Commit(t *testing.T) { + requireBinary(t, "git") + + ctx := context.Background() + dir := filepath.Join(t.TempDir(), "work") + p := NewCLIGitProvider(Config{}) + + require.NoError(t, p.Init(ctx, dir)) + + // Unborn HEAD, nothing staged: no commit. + require.NoError(t, p.Commit(ctx, dir, "empty")) + assert.Equal(t, "0", gitCount(t, dir)) + + require.NoError(t, os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a"), 0o600)) + require.NoError(t, p.Commit(ctx, dir, "first")) + assert.Equal(t, "1", gitCount(t, dir)) + assert.Equal(t, "codebase ", gitRun(t, "-C", dir, "log", "-1", "--format=%an <%ae>")) + + // Clean tree: no commit. + require.NoError(t, p.Commit(ctx, dir, "clean")) + assert.Equal(t, "1", gitCount(t, dir)) + + // Deletion is staged and committed. + require.NoError(t, os.Remove(filepath.Join(dir, "a.txt"))) + require.NoError(t, p.Commit(ctx, dir, "delete")) + assert.Equal(t, "2", gitCount(t, dir)) + + require.NoError(t, p.Commit(ctx, dir, "allow empty", CommitAllowEmpty())) + assert.Equal(t, "3", gitCount(t, dir)) +} + +func gitCount(t *testing.T, dir string) string { + t.Helper() + + cmd := exec.Command("git", "-C", dir, "rev-list", "--count", "--all") + + cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null") + + out, err := cmd.Output() + require.NoError(t, err) + + return strings.TrimSpace(string(out)) +} + +func TestCLIGitProvider_Checkout(t *testing.T) { + requireBinary(t, "git") + + setCADefaults(t, nil, nil) + + ctx := context.Background() + root, bare := newBareRemote(t, "feature") + dst := filepath.Join(root, "work") + p := NewCLIGitProvider(Config{}) + + require.NoError(t, p.Clone(ctx, "file://"+bare, dst)) + + current := func() string { return gitRun(t, "-C", dst, "symbolic-ref", "--short", "HEAD") } + + // remote=true, branch present: plain switch. + require.NoError(t, p.Checkout(ctx, dst, "feature", true)) + assert.Equal(t, "feature", current()) + + // remote=true, branch absent: created from HEAD. + require.NoError(t, p.Checkout(ctx, dst, "fresh", true)) + assert.Equal(t, "fresh", current()) + + // remote=false creates the branch and fails if it exists. + require.NoError(t, p.Checkout(ctx, dst, "local", false)) + assert.Equal(t, "local", current()) + require.Error(t, p.Checkout(ctx, dst, "local", false)) +} + +func TestCLIGitProvider_Checkout_BranchLookupError(t *testing.T) { + requireBinary(t, "git") + + exists, err := NewCLIGitProvider(Config{}).branchExists(context.Background(), t.TempDir(), "main") + + require.Error(t, err, "a failing lookup is not an absent branch") + assert.False(t, exists) +} + +func TestCLIGitProvider_CheckoutRemoteBranch_NoGuess(t *testing.T) { + requireBinary(t, "git") + + setCADefaults(t, nil, nil) + + ctx := context.Background() + root, bare := newBareRemote(t, "stale") + dst := filepath.Join(root, "work") + p := NewCLIGitProvider(Config{}) + + require.NoError(t, p.Clone(ctx, "file://"+bare, dst)) + + // Remote deletes the branch; only the remote-tracking ref survives locally. + gitRun(t, "-C", bare, "branch", "-D", "stale") + gitRun(t, "-C", dst, "branch", "-D", "stale") + require.NotEmpty(t, gitRun(t, "-C", dst, "for-each-ref", "refs/remotes/origin/stale")) + + require.Error(t, p.CheckoutRemoteBranch(ctx, dst, "stale")) + assert.Equal(t, "main", gitRun(t, "-C", dst, "symbolic-ref", "--short", "HEAD")) + + require.Error(t, p.CreateChildBranch(ctx, dst, "stale", "child")) +} + +func TestCLIGitProvider_Checkout_OptionLikeBranchName(t *testing.T) { + requireBinary(t, "git") + + setCADefaults(t, nil, nil) + + ctx := context.Background() + root, bare := newBareRemote(t) + dst := filepath.Join(root, "work") + p := NewCLIGitProvider(Config{}) + + require.NoError(t, p.Clone(ctx, "file://"+bare, dst)) + + for _, name := range []string{"--orphan", "--detach", "--pathspec-from-file=/etc/hosts"} { + err := p.CheckoutRemoteBranch(ctx, dst, name) + + require.ErrorContains(t, err, "invalid reference", name) + assert.Equal(t, "main", gitRun(t, "-C", dst, "symbolic-ref", "--short", "HEAD")) + + require.Error(t, p.CreateChildBranch(ctx, dst, name, "child"), name) + } +} + +func TestCLIGitProvider_originURL(t *testing.T) { + requireBinary(t, "git") + + ctx := context.Background() + dir := filepath.Join(t.TempDir(), "work") + p := NewCLIGitProvider(Config{}) + + require.NoError(t, p.Init(ctx, dir)) + require.NoError(t, p.AddRemoteLink(ctx, dir, "ssh://git.example.com:29418/repo")) + + origin, err := p.originURL(ctx, dir) + require.NoError(t, err) + assert.Equal(t, "ssh://git.example.com:29418/repo", origin) + + _, err = p.originURL(ctx, filepath.Join(t.TempDir(), "missing")) + require.Error(t, err) +} + +// stubGit puts an executable named git first on PATH. +func stubGit(t *testing.T, script string) { + t.Helper() + + binDir := t.TempDir() + stub := filepath.Join(binDir, "git") + require.NoError(t, os.WriteFile(stub, []byte("#!/bin/sh\n"+script), 0o700)) //nolint:gosec // stub must be executable + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) +} + +// processAlive reports whether pid runs. A zombie that PID 1 has not reaped does not count. +func processAlive(pid int) bool { + if syscall.Kill(pid, 0) != nil { + return false + } + + out, err := exec.Command("ps", "-o", "stat=", "-p", strconv.Itoa(pid)).Output() + + return err != nil || !strings.HasPrefix(strings.TrimSpace(string(out)), "Z") +} + +// requireUsablePS skips the test when ps cannot report a process state. +func requireUsablePS(t *testing.T) { + t.Helper() + + if err := exec.Command("ps", "-o", "stat=", "-p", strconv.Itoa(os.Getpid())).Run(); err != nil { + t.Skipf("ps cannot report process state: %v", err) + } +} + +func readPID(t *testing.T, path string) int { + t.Helper() + + var data []byte + + require.Eventually(t, func() bool { + var err error + + data, err = os.ReadFile(path) + + return err == nil && strings.TrimSpace(string(data)) != "" + }, 5*time.Second, 20*time.Millisecond) + + pid, err := strconv.Atoi(strings.TrimSpace(string(data))) + require.NoError(t, err) + + return pid +} + +func TestCLIGitProvider_exec_CancelTerminatesProcessGroup(t *testing.T) { + requireUsablePS(t) + + pidFile := filepath.Join(t.TempDir(), "child.pid") + stubGit(t, "sleep 60 &\necho $! > "+pidFile+"\nwait\n") + + ctx, cancel := context.WithCancel(context.Background()) + errCh := make(chan error, 1) + + go func() { + _, err := NewCLIGitProvider(Config{}).exec(ctx, gitEnv(), "status") + errCh <- err + }() + + child := readPID(t, pidFile) + start := time.Now() + + cancel() + + err := <-errCh + + require.ErrorIs(t, err, context.Canceled) + assert.Less(t, time.Since(start), 10*time.Second) + assert.Eventually(t, func() bool { return !processAlive(child) }, 15*time.Second, 20*time.Millisecond, + "child of git must not outlive the call") +} + +func TestCLIGitProvider_exec_CancelEscalatesToKill(t *testing.T) { + requireUsablePS(t) + + oldGrace, oldDrain := terminateGrace, pipeDrainGrace + terminateGrace, pipeDrainGrace = 300*time.Millisecond, time.Second + + t.Cleanup(func() { terminateGrace, pipeDrainGrace = oldGrace, oldDrain }) + + pidFile := filepath.Join(t.TempDir(), "child.pid") + stubGit(t, "trap '' TERM\n(trap '' TERM; exec sleep 60) &\necho $! > "+pidFile+"\nwhile :; do sleep 1; done\n") + + ctx, cancel := context.WithCancel(context.Background()) + errCh := make(chan error, 1) + + go func() { + _, err := NewCLIGitProvider(Config{}).exec(ctx, gitEnv(), "status") + errCh <- err + }() + + child := readPID(t, pidFile) + start := time.Now() + + cancel() + + select { + case err := <-errCh: + require.ErrorIs(t, err, context.Canceled) + case <-time.After(10 * time.Second): + t.Fatal("exec did not return after cancellation") + } + + assert.Less(t, time.Since(start), 10*time.Second) + assert.Eventually(t, func() bool { return !processAlive(child) }, 15*time.Second, 20*time.Millisecond, + "SIGKILL must reach children that ignore SIGTERM") +} + +func TestCLIGitProvider_exec_ExitCode(t *testing.T) { + stubGit(t, "exit 3\n") + + _, err := NewCLIGitProvider(Config{}).exec(context.Background(), gitEnv(), "status") + + require.Error(t, err) + assert.True(t, hasExitCode(err, 3)) + assert.False(t, hasExitCode(err, 1)) + assert.False(t, errors.Is(err, context.Canceled)) +} + +func TestCLIGitProvider_Clone_LocalBranchesAndTags(t *testing.T) { + requireBinary(t, "git") + + setCADefaults(t, nil, nil) + + root, bare := newBareRemote(t, "dev", "feat/x") + gitRun(t, "-C", bare, "tag", "v1", "main") + + dst := filepath.Join(root, "work") + require.NoError(t, NewCLIGitProvider(Config{}).Clone(context.Background(), "file://"+bare, dst)) + + assert.ElementsMatch(t, []string{"main", "dev", "feat/x"}, + strings.Fields(gitRun(t, "-C", dst, "for-each-ref", "--format=%(refname:short)", "refs/heads"))) + assert.Equal(t, "v1", gitRun(t, "-C", dst, "tag")) + assert.Equal(t, "main", gitRun(t, "-C", dst, "symbolic-ref", "--short", "HEAD")) + assert.Empty(t, gitRun(t, "-C", dst, "status", "--porcelain")) +} + +func TestCLIGitProvider_Clone_RejectsExternalProtocols(t *testing.T) { + requireBinary(t, "git") + + setCADefaults(t, nil, nil) + + root := t.TempDir() + proof := filepath.Join(root, "proof") + dst := filepath.Join(root, "work") + + err := NewCLIGitProvider(Config{}).Clone(context.Background(), "ext::sh -c 'touch "+proof+"'", dst) + + require.Error(t, err) + assert.NoFileExists(t, proof) +} + +func TestCLIGitProvider_Push_OptionLikeRefspec(t *testing.T) { + requireBinary(t, "git") + + setCADefaults(t, nil, nil) + + ctx := context.Background() + root, bare := newBareRemote(t) + dst := filepath.Join(root, "work") + p := NewCLIGitProvider(Config{}) + + require.NoError(t, p.Clone(ctx, "file://"+bare, dst)) + + err := p.Push(ctx, dst, "--receive-pack=false") + + require.Error(t, err) + assert.Contains(t, err.Error(), "--receive-pack=false", "git must read it as a refspec") +} + +func TestCLIGitProvider_Push_MissingOrigin(t *testing.T) { + requireBinary(t, "git") + + ctx := context.Background() + dir := filepath.Join(t.TempDir(), "work") + p := NewCLIGitProvider(Config{}) + + require.NoError(t, p.Init(ctx, dir)) + + err := p.Push(ctx, dir, "refs/heads/main:refs/heads/main") + require.ErrorContains(t, err, "failed to read origin URL") +} + +func TestTailBuffer(t *testing.T) { + b := &tailBuffer{limit: 5} + + n, err := b.Write([]byte("abc")) + require.NoError(t, err) + assert.Equal(t, 3, n) + assert.Equal(t, "abc", b.String()) + + _, _ = b.Write([]byte("defgh")) + assert.Equal(t, "defgh", b.String()) + + for range 100 { + _, _ = b.Write([]byte("xy")) + } + + assert.Equal(t, "yxyxy", b.String()) + assert.LessOrEqual(t, len(b.data), 3*b.limit) +} + +func TestCLIGitProvider_exec_LingeringChildAfterSuccess(t *testing.T) { + oldGrace, oldDrain := terminateGrace, pipeDrainGrace + terminateGrace, pipeDrainGrace = 100*time.Millisecond, 200*time.Millisecond + + t.Cleanup(func() { terminateGrace, pipeDrainGrace = oldGrace, oldDrain }) + + requireUsablePS(t) + + pidFile := filepath.Join(t.TempDir(), "child.pid") + stubGit(t, "sleep 30 &\necho $! > '"+pidFile+"'\necho done\nexit 0\n") + + out, err := NewCLIGitProvider(Config{}).exec(context.Background(), gitEnv(), "status") + + require.NoError(t, err) + assert.Equal(t, "done\n", out) + + child := readPID(t, pidFile) + assert.Eventually(t, func() bool { return !processAlive(child) }, 15*time.Second, 20*time.Millisecond, + "lingering child must not outlive exec") +} + +func TestCLIGitProvider_exec_SignalDeathKillsChildren(t *testing.T) { + requireUsablePS(t) + + oldGrace, oldDrain := terminateGrace, pipeDrainGrace + terminateGrace, pipeDrainGrace = 100*time.Millisecond, 200*time.Millisecond + + t.Cleanup(func() { terminateGrace, pipeDrainGrace = oldGrace, oldDrain }) + + pidFile := filepath.Join(t.TempDir(), "child.pid") + stubGit(t, "sleep 30 &\necho $! > '"+pidFile+"'\nkill -9 $$\n") + + _, err := NewCLIGitProvider(Config{}).exec(context.Background(), gitEnv(), "status") + + require.Error(t, err) + + child := readPID(t, pidFile) + assert.Eventually(t, func() bool { return !processAlive(child) }, 15*time.Second, 20*time.Millisecond, + "child must not outlive a git process that a signal ended") +} + +func TestCLIGitProvider_Commit_DoesNotUseParentRepository(t *testing.T) { + requireBinary(t, "git") + + ctx := context.Background() + parent := filepath.Join(t.TempDir(), "parent") + p := NewCLIGitProvider(Config{}) + + require.NoError(t, p.Init(ctx, parent)) + + child := filepath.Join(parent, "child") + require.NoError(t, os.MkdirAll(child, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(child, "a.txt"), []byte("a"), 0o600)) + + require.Error(t, p.Commit(ctx, child, "msg")) + + out, err := exec.Command("git", "-C", parent, "rev-list", "--all", "--count").Output() + require.NoError(t, err) + assert.Equal(t, "0", strings.TrimSpace(string(out))) +} + +func TestCLIGitProvider_Commit_EmptyMessage(t *testing.T) { + requireBinary(t, "git") + + ctx := context.Background() + dir := filepath.Join(t.TempDir(), "work") + p := NewCLIGitProvider(Config{}) + + require.NoError(t, p.Init(ctx, dir)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a"), 0o600)) + require.NoError(t, p.Commit(ctx, dir, "")) + assert.Equal(t, "1", gitCount(t, dir)) +} + +func TestCLIGitProvider_exec_SignalDeathRemovesLocks(t *testing.T) { + repo := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(repo, ".git"), 0o755)) //nolint:gosec // test directory + + index := filepath.Join(repo, ".git", "index.lock") + stubGit(t, "touch '"+index+"'\nkill -9 $$\n") + + _, err := NewCLIGitProvider(Config{}).exec(context.Background(), gitEnv(), "-C", repo, "add", "--all") + + require.Error(t, err) + assert.NoFileExists(t, index) +} +func TestCLIGitProvider_Push_DefaultsToAllBranches(t *testing.T) { + requireBinary(t, "git") + + setCADefaults(t, nil, nil) + + ctx := context.Background() + root, bare := newBareRemote(t, "dev") + dst := filepath.Join(root, "work") + p := NewCLIGitProvider(Config{}) + + require.NoError(t, p.Clone(ctx, "file://"+bare, dst)) + require.NoError(t, p.CheckoutRemoteBranch(ctx, dst, "dev")) + require.NoError(t, os.WriteFile(filepath.Join(dst, "b.txt"), []byte("b"), 0o600)) + require.NoError(t, p.Commit(ctx, dst, "add b")) + require.NoError(t, p.Push(ctx, dst)) + + assert.Equal(t, gitRun(t, "-C", dst, "rev-parse", "dev"), gitRun(t, "-C", bare, "rev-parse", "dev")) +} + +func TestCLIGitProvider_exec_KillRemovesLocks(t *testing.T) { + oldGrace, oldDrain := terminateGrace, pipeDrainGrace + terminateGrace, pipeDrainGrace = 300*time.Millisecond, time.Second + + t.Cleanup(func() { terminateGrace, pipeDrainGrace = oldGrace, oldDrain }) + + repo := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(repo, ".git", "refs", "heads"), 0o755)) //nolint:gosec // test directory + + index := filepath.Join(repo, ".git", "index.lock") + ref := filepath.Join(repo, ".git", "refs", "heads", "main.lock") + keep := filepath.Join(repo, ".git", "config") + require.NoError(t, os.WriteFile(keep, []byte("x"), 0o600)) + + stubGit(t, "trap '' TERM\ntouch '"+index+"' '"+ref+"'\nwhile :; do sleep 1; done\n") + + ctx, cancel := context.WithCancel(context.Background()) + errCh := make(chan error, 1) + + go func() { + _, err := NewCLIGitProvider(Config{}).exec(ctx, gitEnv(), "-C", repo, "add", "--all") + errCh <- err + }() + + require.Eventually(t, func() bool { + _, err := os.Stat(ref) + + return err == nil + }, 5*time.Second, 20*time.Millisecond) + + cancel() + + require.ErrorIs(t, <-errCh, context.Canceled) + assert.NoFileExists(t, index) + assert.NoFileExists(t, ref) + assert.FileExists(t, keep) +} + +func TestCLIGitProvider_Clone_FailureKeepsEmptyDestination(t *testing.T) { + requireBinary(t, "git") + + setCADefaults(t, nil, nil) + + root := t.TempDir() + bare := filepath.Join(root, "empty.git") + gitRun(t, "init", "--bare", "-b", "main", bare) + + dst := filepath.Join(root, "work") + require.NoError(t, os.MkdirAll(dst, 0o750)) + + err := NewCLIGitProvider(Config{}).Clone(context.Background(), "file://"+bare, dst) + + require.ErrorIs(t, err, transport.ErrEmptyRemoteRepository) + assert.DirExists(t, dst) + + entries, err := os.ReadDir(dst) + require.NoError(t, err) + assert.Empty(t, entries) +} + +func TestRepoDir(t *testing.T) { + assert.Equal(t, "/r", repoDir([]string{"-C", "/r", "add"})) + assert.Equal(t, "/r", repoDir([]string{"-c", "a=b", "-C", "/r", "add"})) + assert.Empty(t, repoDir([]string{"clone", "-C", "/r"}), "-C after the subcommand is a subcommand option") + assert.Empty(t, repoDir([]string{"status"})) + assert.Empty(t, repoDir(nil)) +} + +func TestNormalizeKey(t *testing.T) { + assert.Equal(t, "a\nb\n", normalizeKey("\r\na\r\nb\r\n\r\n")) + assert.Equal(t, "a\nb\n", normalizeKey("a\nb")) +} diff --git a/pkg/git/cli_ca_test.go b/pkg/git/cli_ca_test.go new file mode 100644 index 00000000..df55dce4 --- /dev/null +++ b/pkg/git/cli_ca_test.go @@ -0,0 +1,200 @@ +package v2 + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "net/http/cgi" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func certPEM(t *testing.T, cn string) []byte { + t.Helper() + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(time.Now().UnixNano()), + Subject: pkix.Name{CommonName: cn}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + IsCA: true, + } + + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + require.NoError(t, err) + + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) +} + +func setCADefaults(t *testing.T, files, dirs []string) { + t.Helper() + + oldFiles, oldDirs := caFiles, caDirs + caFiles, caDirs = files, dirs + + t.Cleanup(func() { caFiles, caDirs = oldFiles, oldDirs }) + t.Setenv("SSL_CERT_FILE", "") + t.Setenv("SSL_CERT_DIR", "") +} + +func bundleCerts(t *testing.T, path string) []string { + t.Helper() + + data, err := os.ReadFile(path) + require.NoError(t, err) + + var names []string + + for { + var block *pem.Block + + block, data = pem.Decode(data) + if block == nil { + return names + } + + cert, err := x509.ParseCertificate(block.Bytes) + require.NoError(t, err) + + names = append(names, cert.Subject.CommonName) + } +} + +func TestWriteCABundle_DefaultLocations(t *testing.T) { + root := t.TempDir() + certsDir := filepath.Join(root, "certs") + require.NoError(t, os.Mkdir(certsDir, 0o700)) + + system := certPEM(t, "system") + systemFile := filepath.Join(root, "ca-certificates.crt") + require.NoError(t, os.WriteFile(systemFile, system, 0o600)) + + require.NoError(t, os.WriteFile(filepath.Join(certsDir, "gitlab-ca.crt"), certPEM(t, "mounted"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(certsDir, "copy-of-system.pem"), system, 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(certsDir, "notes.txt"), []byte("ignore"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(certsDir, "broken.pem"), + []byte("-----BEGIN CERTIFICATE-----\nQUFB\n-----END CERTIFICATE-----\n"), 0o600)) + + setCADefaults(t, + []string{filepath.Join(root, "missing.crt"), systemFile}, + []string{certsDir, filepath.Join(root, "absent")}, + ) + + path, err := writeCABundle() + require.NoError(t, err) + + t.Cleanup(func() { _ = os.Remove(path) }) + + assert.ElementsMatch(t, []string{"system", "mounted"}, bundleCerts(t, path)) +} + +func TestWriteCABundle_EnvOverridesDefaults(t *testing.T) { + root := t.TempDir() + defaultFile := filepath.Join(root, "default.crt") + require.NoError(t, os.WriteFile(defaultFile, certPEM(t, "default"), 0o600)) + + envFile := filepath.Join(root, "env.crt") + require.NoError(t, os.WriteFile(envFile, certPEM(t, "env-file"), 0o600)) + + envDir := filepath.Join(root, "envdir") + require.NoError(t, os.Mkdir(envDir, 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(envDir, "custom.crt"), certPEM(t, "env-dir"), 0o600)) + + setCADefaults(t, []string{defaultFile}, []string{root}) + t.Setenv("SSL_CERT_FILE", envFile) + t.Setenv("SSL_CERT_DIR", envDir) + + path, err := writeCABundle() + require.NoError(t, err) + + t.Cleanup(func() { _ = os.Remove(path) }) + + assert.ElementsMatch(t, []string{"env-file", "env-dir"}, bundleCerts(t, path)) +} + +func TestCLIGitProvider_buildEnv_NoCA(t *testing.T) { + setCADefaults(t, []string{filepath.Join(t.TempDir(), "missing")}, nil) + + env, cleanup, err := NewCLIGitProvider(Config{}).buildEnv("https://git.example.com/repo") + require.NoError(t, err) + + defer cleanup() + + assert.NotContains(t, envMap(env), "GIT_SSL_CAINFO") +} + +func TestCLIGitProvider_buildEnv_CABundleRemoved(t *testing.T) { + f := filepath.Join(t.TempDir(), "ca.crt") + require.NoError(t, os.WriteFile(f, certPEM(t, "ca"), 0o600)) + setCADefaults(t, []string{f}, nil) + + env, cleanup, err := NewCLIGitProvider(Config{}).buildEnv("https://git.example.com/repo") + require.NoError(t, err) + + bundle := envMap(env)["GIT_SSL_CAINFO"] + require.FileExists(t, bundle) + + cleanup() + + assert.NoFileExists(t, bundle) +} + +// TestCLIGitProvider_Clone_HTTPSCustomCA serves a repository over HTTPS with a self-signed +// certificate. The CA file sits in a trusted directory without an OpenSSL hashed name. +func TestCLIGitProvider_Clone_HTTPSCustomCA(t *testing.T) { + gitBin, err := exec.LookPath("git") + if err != nil { + t.Skip("git binary not available") + } + + root := t.TempDir() + seed := filepath.Join(root, "seed") + gitRun(t, "init", "-b", "main", seed) + require.NoError(t, os.WriteFile(filepath.Join(seed, "a.txt"), []byte("a"), 0o600)) + gitRun(t, "-C", seed, "add", ".") + gitRun(t, "-C", seed, "commit", "-m", "init") + gitRun(t, "clone", "--bare", seed, filepath.Join(root, "srv", "repo.git")) + + srv := httptest.NewTLSServer(&cgi.Handler{ + Path: gitBin, + Args: []string{"http-backend"}, + Env: []string{ + "GIT_PROJECT_ROOT=" + filepath.Join(root, "srv"), + "GIT_HTTP_EXPORT_ALL=1", + }, + }) + defer srv.Close() + + certsDir := filepath.Join(root, "certs") + require.NoError(t, os.Mkdir(certsDir, 0o700)) + + setCADefaults(t, []string{filepath.Join(root, "missing")}, []string{certsDir}) + + repoURL := srv.URL + "/repo.git" + p := NewCLIGitProvider(Config{}) + + require.Error(t, p.Clone(context.Background(), repoURL, filepath.Join(root, "untrusted")), + "clone must fail before the CA is trusted") + + caPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: srv.Certificate().Raw}) + require.NoError(t, os.WriteFile(filepath.Join(certsDir, "server-ca.crt"), caPEM, 0o600)) + + dst := filepath.Join(root, "trusted") + require.NoError(t, p.Clone(context.Background(), repoURL, dst)) + assert.FileExists(t, filepath.Join(dst, "a.txt")) +} diff --git a/pkg/git/cli_test.go b/pkg/git/cli_test.go new file mode 100644 index 00000000..8a37318c --- /dev/null +++ b/pkg/git/cli_test.go @@ -0,0 +1,465 @@ +package v2 + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/pem" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/knownhosts" + + corev1 "k8s.io/api/core/v1" + + codebaseApi "github.com/epam/edp-codebase-operator/v2/api/v1" +) + +func envMap(env []string) map[string]string { + m := make(map[string]string, len(env)) + + for _, e := range env { + k, v, _ := strings.Cut(e, "=") + m[k] = v + } + + return m +} + +// gitConfigMap returns the GIT_CONFIG_COUNT/KEY_n/VALUE_n entries of env. +func gitConfigMap(t *testing.T, env []string) map[string]string { + t.Helper() + + m := envMap(env) + count, err := strconv.Atoi(m["GIT_CONFIG_COUNT"]) + require.NoError(t, err) + + cfg := make(map[string]string, count) + + for i := range count { + cfg[m[fmt.Sprintf("GIT_CONFIG_KEY_%d", i)]] = m[fmt.Sprintf("GIT_CONFIG_VALUE_%d", i)] + } + + return cfg +} + +func TestCLIGitProvider_buildEnv_SSH(t *testing.T) { + t.Setenv("SSH_KNOWN_HOSTS", "/etc/known_hosts") + setCADefaults(t, nil, nil) + + key := testSSHKey(t) + p := NewCLIGitProvider(Config{SSHKey: key, SSHUser: "gerrit", Token: "tok"}) + + env, cleanup, err := p.buildEnv("") + require.NoError(t, err) + + m := envMap(env) + cmd := m["GIT_SSH_COMMAND"] + + assert.Contains(t, cmd, "-o StrictHostKeyChecking=yes") + assert.Contains(t, cmd, "-o IdentitiesOnly=yes") + assert.Contains(t, cmd, "-o BatchMode=yes") + assert.Contains(t, cmd, `'UserKnownHostsFile="/etc/known_hosts"'`) + assert.Contains(t, cmd, "-l 'gerrit'") + assert.Contains(t, cmd, "HostKeyAlgorithms=+ssh-rsa") + assert.Contains(t, cmd, "PubkeyAcceptedAlgorithms=+ssh-rsa") + assert.NotContains(t, gitConfigMap(t, env), "http.extraHeader", "SSH key wins over token") + assert.NotContains(t, m, "GIT_SSL_CAINFO", "SSH needs no CA bundle") + assert.Equal(t, "0", m["GIT_TERMINAL_PROMPT"]) + assert.Equal(t, "1", m["GIT_CONFIG_NOSYSTEM"]) + assert.Equal(t, os.TempDir(), m["HOME"]) + + keyFile := strings.Split(strings.Split(cmd, "-i '")[1], "'")[0] + info, err := os.Stat(keyFile) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) + + data, err := os.ReadFile(keyFile) + require.NoError(t, err) + assert.Equal(t, strings.TrimSpace(key)+"\n", string(data)) + + cleanup() + + _, err = os.Stat(keyFile) + assert.True(t, os.IsNotExist(err)) +} + +func TestCLIGitProvider_buildEnv_Token(t *testing.T) { + setCADefaults(t, nil, nil) + + tests := []struct { + name string + cfg Config + wantAuth string + }{ + { + name: "github", + cfg: Config{GitProvider: codebaseApi.GitProviderGithub, Username: "u", Token: "s3cret"}, + wantAuth: "u:s3cret", + }, + { + name: "gitlab", + cfg: Config{GitProvider: codebaseApi.GitProviderGitlab, Username: "u", Token: "s3cret"}, + wantAuth: "oauth2:s3cret", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + env, cleanup, err := NewCLIGitProvider(tt.cfg).buildEnv("https://git.example.com:8443/org/repo.git") + require.NoError(t, err) + + defer cleanup() + + cfg := gitConfigMap(t, env) + + assert.Equal(t, "Authorization: Basic "+basicAuth(strings.Split(tt.wantAuth, ":")[0], "s3cret"), + cfg["http.https://git.example.com:8443/.extraHeader"]) + assert.NotContains(t, cfg, "http.extraHeader", "token must be scoped to the remote host") + assert.NotContains(t, envMap(env), "GIT_SSH_COMMAND") + }) + } +} + +func TestCLIGitProvider_buildEnv_Anonymous(t *testing.T) { + setCADefaults(t, nil, nil) + + env, cleanup, err := NewCLIGitProvider(Config{}).buildEnv("") + require.NoError(t, err) + + defer cleanup() + + m := envMap(env) + assert.NotContains(t, m, "GIT_SSH_COMMAND") + assert.NotContains(t, gitConfigMap(t, env), "http.extraHeader") + assert.Equal(t, "0", m["GIT_TERMINAL_PROMPT"]) +} + +func TestBaseEnv_Config(t *testing.T) { + m := envMap(gitEnv()) + + assert.Equal(t, os.DevNull, m["GIT_CONFIG_GLOBAL"]) + assert.Equal(t, "1", m["GIT_CONFIG_NOSYSTEM"]) + assert.Equal(t, map[string]string{ + "gc.auto": "0", + "maintenance.auto": "false", + "pack.threads": "1", + }, gitConfigMap(t, gitEnv())) +} + +func TestBaseEnv_Proxy(t *testing.T) { + t.Setenv("HTTP_PROXY", "http://upper:3128") + t.Setenv("http_proxy", "http://lower:3128") + t.Setenv("HTTPS_PROXY", "") + t.Setenv("https_proxy", "http://only-lower:3128") + t.Setenv("NO_PROXY", "") + t.Setenv("no_proxy", "") + + m := envMap(gitEnv()) + + assert.Equal(t, "http://upper:3128", m["HTTP_PROXY"], "Go prefers the uppercase name") + assert.Equal(t, "http://upper:3128", m["http_proxy"], "libcurl reads the lowercase name") + assert.Equal(t, "http://only-lower:3128", m["HTTPS_PROXY"]) + assert.Equal(t, "http://only-lower:3128", m["https_proxy"]) + assert.NotContains(t, m, "NO_PROXY") + assert.NotContains(t, m, "no_proxy") +} + +func TestCLIGitProvider_redact(t *testing.T) { + p := NewCLIGitProvider(Config{Username: "u", Token: "s3cret", SSHKey: "KEYDATA"}) + out := p.redact("fail s3cret " + basicAuth("u", "s3cret") + " KEYDATA") + + assert.NotContains(t, out, "s3cret") + assert.NotContains(t, out, "KEYDATA") + assert.NotContains(t, out, basicAuth("u", "s3cret")) +} + +func TestCLIError_HostKey(t *testing.T) { + err := remoteErr(cliError(assert.AnError, "Host key verification failed."), "ssh://git.example.com:29418/repo") + assert.ErrorContains(t, err, "not present in known_hosts") + assert.ErrorContains(t, err, "git.example.com:29418") + + err = remoteErr(cliError(assert.AnError, "REMOTE HOST IDENTIFICATION HAS CHANGED!\nHost key verification failed."), + "git@git.example.com:repo.git") + assert.ErrorContains(t, err, "host key mismatch") +} + +func TestProviderFactories_ReturnCLIProvider(t *testing.T) { + assert.IsType(t, &CLIGitProvider{}, NewGitProviderFactory(Config{})) + assert.IsType(t, &CLIGitProvider{}, DefaultGitProviderFactory(&codebaseApi.GitServer{}, &corev1.Secret{})) +} + +func gitRun(t *testing.T, args ...string) string { + t.Helper() + + cmd := exec.Command("git", args...) + + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.com", + "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.com", + "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", + ) + + out, err := cmd.CombinedOutput() + require.NoError(t, err, string(out)) + + return strings.TrimSpace(string(out)) +} + +func TestCLIGitProvider_CloneAndPush(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not on PATH") + } + + setCADefaults(t, nil, nil) + + ctx := context.Background() + root := t.TempDir() + seed := filepath.Join(root, "seed") + bare := filepath.Join(root, "remote.git") + + gitRun(t, "init", "-b", "main", seed) + require.NoError(t, os.WriteFile(filepath.Join(seed, "a.txt"), []byte("a"), 0o600)) + gitRun(t, "-C", seed, "add", ".") + gitRun(t, "-C", seed, "commit", "-m", "init") + gitRun(t, "-C", seed, "branch", "feature") + gitRun(t, "-C", seed, "tag", "v1") + gitRun(t, "clone", "--bare", seed, bare) + + url := "file://" + bare + dst := filepath.Join(root, "work") + p := NewCLIGitProvider(Config{}) + + require.NoError(t, p.Clone(ctx, url, dst)) + + assert.Equal(t, "main", gitRun(t, "-C", dst, "rev-parse", "--abbrev-ref", "HEAD")) + assert.Equal(t, "origin", gitRun(t, "-C", dst, "remote")) + assert.Contains(t, gitRun(t, "-C", dst, "for-each-ref", "--format=%(refname)", "refs/heads"), "refs/heads/feature") + assert.Equal(t, "v1", gitRun(t, "-C", dst, "tag", "--list")) + assert.FileExists(t, filepath.Join(dst, "a.txt")) + + gitRun(t, "-C", dst, "branch", "new-branch") + require.NoError(t, p.Push(ctx, dst, RefSpecPushAllBranches)) + assert.Contains(t, gitRun(t, "-C", bare, "branch", "--list"), "new-branch") + + // Re-push is a no-op success. + require.NoError(t, p.Push(ctx, dst, RefSpecPushAllBranches, RefSpecPushAllTags)) + + // Non-fast-forward is rejected. + gitRun(t, "-C", dst, "checkout", "-q", "new-branch") + require.NoError(t, os.WriteFile(filepath.Join(dst, "b.txt"), []byte("b"), 0o600)) + gitRun(t, "-C", dst, "add", ".") + gitRun(t, "-C", dst, "commit", "-m", "b") + require.NoError(t, p.Push(ctx, dst, "refs/heads/new-branch:refs/heads/new-branch")) + gitRun(t, "-C", dst, "reset", "--hard", "-q", "HEAD~1") + require.Error(t, p.Push(ctx, dst, "refs/heads/new-branch:refs/heads/new-branch")) +} + +func TestCLIGitProvider_Clone_CloneFailureRemovesDestination(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not on PATH") + } + + setCADefaults(t, nil, nil) + + root := t.TempDir() + dst := filepath.Join(root, "work") + p := NewCLIGitProvider(Config{}) + + err := p.Clone(context.Background(), "file://"+filepath.Join(root, "missing.git"), dst) + + require.Error(t, err) + assert.NoDirExists(t, dst) +} + +func TestCLIGitProvider_Clone_BranchCreationFailureRemovesDestination(t *testing.T) { + realGit, err := exec.LookPath("git") + if err != nil { + t.Skip("git not on PATH") + } + + setCADefaults(t, nil, nil) + + root := t.TempDir() + seed := filepath.Join(root, "seed") + bare := filepath.Join(root, "remote.git") + + gitRun(t, "init", "-b", "main", seed) + require.NoError(t, os.WriteFile(filepath.Join(seed, "a.txt"), []byte("a"), 0o600)) + gitRun(t, "-C", seed, "add", ".") + gitRun(t, "-C", seed, "commit", "-m", "init") + gitRun(t, "clone", "--bare", seed, bare) + + binDir := t.TempDir() + script := "#!/bin/sh\nfor a in \"$@\"; do [ \"$a\" = fetch ] && exit 1; done\nexec " + realGit + " \"$@\"\n" + stub := filepath.Join(binDir, "git") + require.NoError(t, os.WriteFile(stub, []byte(script), 0o700)) //nolint:gosec // stub must be executable + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + dst := filepath.Join(root, "work") + p := NewCLIGitProvider(Config{}) + + err = p.Clone(context.Background(), "file://"+bare, dst) + + require.ErrorContains(t, err, "failed to create local branches") + assert.NoDirExists(t, dst) +} + +func TestCLIGitProvider_buildEnv_TokenScope(t *testing.T) { + setCADefaults(t, nil, nil) + + for _, remote := range []string{"", "git@git.example.com:org/repo.git", "ssh://git.example.com/repo", "/srv/repo"} { + env, cleanup, err := NewCLIGitProvider(Config{Username: "u", Token: "tok"}).buildEnv(remote) + require.NoError(t, err) + + for key := range gitConfigMap(t, env) { + assert.NotContains(t, key, "extraHeader", "remote %q", remote) + } + + cleanup() + } +} + +func TestCLIGitProvider_buildEnv_StallBounds(t *testing.T) { + setCADefaults(t, nil, nil) + + env, cleanup, err := NewCLIGitProvider(Config{}).buildEnv("https://git.example.com/repo") + require.NoError(t, err) + + defer cleanup() + + cfg := gitConfigMap(t, env) + assert.Equal(t, "1", cfg["http.lowSpeedLimit"]) + assert.Equal(t, "600", cfg["http.lowSpeedTime"]) + + assert.NotContains(t, gitConfigMap(t, gitEnv()), "http.lowSpeedLimit", "local calls have no network bound") + + sshEnv, sshCleanup, err := NewCLIGitProvider(Config{SSHKey: testSSHKey(t)}).buildEnv("") + require.NoError(t, err) + + defer sshCleanup() + + cmd := envMap(sshEnv)["GIT_SSH_COMMAND"] + assert.Contains(t, cmd, "ConnectTimeout=30") + assert.Contains(t, cmd, "ServerAliveInterval=15") + assert.Contains(t, cmd, "ServerAliveCountMax=4") +} + +func TestCLIGitProvider_sshCommand_KnownHostsList(t *testing.T) { + t.Setenv("SSH_KNOWN_HOSTS", "/etc/a"+string(os.PathListSeparator)+"/etc/b") + + cmd := NewCLIGitProvider(Config{}).sshCommand("/k") + assert.Contains(t, cmd, `'UserKnownHostsFile="/etc/a" "/etc/b"'`) +} + +func TestCLIGitProvider_buildEnv_AllowedProtocols(t *testing.T) { + setCADefaults(t, nil, nil) + + for _, cfg := range []Config{{}, {SSHKey: testSSHKey(t)}, {Token: "tok"}} { + env, cleanup, err := NewCLIGitProvider(cfg).buildEnv("https://git.example.com/repo") + require.NoError(t, err) + + assert.Equal(t, allowedProtocols, envMap(env)["GIT_ALLOW_PROTOCOL"]) + + cleanup() + } + + assert.NotContains(t, envMap(gitEnv()), "GIT_ALLOW_PROTOCOL") +} + +func TestCLIGitProvider_sshCommand_GlobalKnownHosts(t *testing.T) { + t.Setenv("SSH_KNOWN_HOSTS", "/etc/known_hosts") + + cmd := NewCLIGitProvider(Config{}).sshCommand("/k") + assert.Contains(t, cmd, "'GlobalKnownHostsFile="+os.DevNull+"'") + + t.Setenv("SSH_KNOWN_HOSTS", "") + + cmd = NewCLIGitProvider(Config{}).sshCommand("/k") + assert.NotContains(t, cmd, "GlobalKnownHostsFile") +} + +func TestCliError_Revoked(t *testing.T) { + err := cliError(errors.New("exit status 128"), + "@ WARNING: REVOKED HOST KEY DETECTED! @\nHost key verification failed.") + + var revoked *knownhosts.RevokedError + + require.ErrorAs(t, err, &revoked) + + var keyErr *knownhosts.KeyError + + assert.False(t, errors.As(err, &keyErr)) +} + +func testSSHKey(t *testing.T) string { + t.Helper() + + _, priv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + block, err := ssh.MarshalPrivateKey(priv, "") + require.NoError(t, err) + + return string(pem.EncodeToMemory(block)) +} + +func TestCLIGitProvider_buildEnv_InvalidSSHKey(t *testing.T) { + _, cleanup, err := NewCLIGitProvider(Config{SSHKey: "not-a-key"}).buildEnv("") + defer cleanup() + + require.ErrorContains(t, err, "failed to parse SSH private key") +} + +func TestQuoteHostsFiles(t *testing.T) { + assert.Equal(t, `"/etc/my certs/kh" "/x%%y"`, quoteHostsFiles([]string{"/etc/my certs/kh", "/x%y"})) +} + +func TestDefaultAllowedProtocols(t *testing.T) { + assert.Equal(t, "http:https:ssh", defaultAllowedProtocols) +} + +func TestCLIGitProvider_buildEnv_KeylessSSHRemote(t *testing.T) { + t.Setenv("SSH_KNOWN_HOSTS", "/etc/known_hosts") + setCADefaults(t, nil, nil) + + for _, remote := range []string{ + "ssh://alice@git.example.com:2222/org/repo.git", + "git@git.example.com:org/repo.git", + } { + env, cleanup, err := NewCLIGitProvider(Config{}).buildEnv(remote) + require.NoError(t, err, remote) + + cmd := envMap(env)["GIT_SSH_COMMAND"] + + assert.Contains(t, cmd, "-o StrictHostKeyChecking=yes", remote) + assert.Contains(t, cmd, "-o BatchMode=yes", remote) + assert.Contains(t, cmd, `'UserKnownHostsFile="/etc/known_hosts"'`, remote) + assert.Contains(t, cmd, "ServerAliveInterval=", remote) + assert.NotContains(t, cmd, " -i ", remote) + assert.NotContains(t, cmd, " -l ", remote, "the URL user applies") + assert.Contains(t, envMap(env), "GIT_ALLOW_PROTOCOL", remote) + + cleanup() + } +} + +func TestCLIGitProvider_buildEnv_HTTPRemoteHasNoSSHCommand(t *testing.T) { + setCADefaults(t, nil, nil) + + env, cleanup, err := NewCLIGitProvider(Config{Token: "tok"}).buildEnv("https://git.example.com/org/repo.git") + require.NoError(t, err) + + defer cleanup() + + assert.NotContains(t, envMap(env), "GIT_SSH_COMMAND") +} diff --git a/pkg/git/factory.go b/pkg/git/factory.go index 55a5c01d..1780e6a2 100644 --- a/pkg/git/factory.go +++ b/pkg/git/factory.go @@ -13,11 +13,11 @@ type GitProviderFactory func(cfg Config) Git // DefaultGitProviderFactory is the default factory implementation for creating GitProvider instances. func DefaultGitProviderFactory(gitServer *codebaseApi.GitServer, secret *corev1.Secret) Git { - return NewGitProvider(NewConfigFromGitServerAndSecret(gitServer, secret)) + return NewCLIGitProvider(NewConfigFromGitServerAndSecret(gitServer, secret)) } func NewGitProviderFactory(cfg Config) Git { - return NewGitProvider(cfg) + return NewCLIGitProvider(cfg) } func NewConfigFromGitServerAndSecret(gitServer *codebaseApi.GitServer, secret *corev1.Secret) Config { diff --git a/pkg/git/provider.go b/pkg/git/provider.go index 8d620553..1a08a2c0 100644 --- a/pkg/git/provider.go +++ b/pkg/git/provider.go @@ -4,13 +4,10 @@ import ( "context" "errors" "fmt" - "os" - "time" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/config" "github.com/go-git/go-git/v5/plumbing" - "github.com/go-git/go-git/v5/plumbing/object" "github.com/go-git/go-git/v5/plumbing/transport" "github.com/go-git/go-git/v5/plumbing/transport/http" "github.com/go-git/go-git/v5/plumbing/transport/ssh" @@ -35,6 +32,11 @@ const ( // RefSpecPushAllTags is the refspec for pushing all tags. // Equivalent to git push --tags RefSpecPushAllTags = "refs/tags/*:refs/tags/*" + + // refSpecFetchBranches and refSpecFetchTags are the only namespaces fetched. + // refs/pull, refs/merge-requests and refs/changes are excluded. + refSpecFetchBranches = "+refs/heads/*:refs/heads/*" + refSpecFetchTags = "+refs/tags/*:refs/tags/*" ) // commitOps holds options for commit operations. @@ -69,7 +71,7 @@ type Config struct { Username string // Username for token auth (required for Bitbucket) } -// GitProvider provides git operations using go-git library exclusively. +// GitProvider runs the go-git operations: local ref operations, init, remote link, packless remote operations. type GitProvider struct { config Config } @@ -137,256 +139,20 @@ func sshTarget(repoURL string) string { return sshhostkey.HostPort(ep.Host, ep.Port) } -// originURL lets directory-based operations name the host they failed to verify. -func originURL(repo *git.Repository) string { - remote, err := repo.Remote("origin") - if err != nil || len(remote.Config().URLs) == 0 { - return "" +// tokenUsername is the basic auth user for token auth: oauth2 for GitLab, Username otherwise. +func (p *GitProvider) tokenUsername() string { + if p.config.GitProvider == codebaseApi.GitProviderGitlab { + return "oauth2" } - return remote.Config().URLs[0] + return p.config.Username } -// getTokenAuth formats token authentication based on the git provider type. func (p *GitProvider) getTokenAuth() transport.AuthMethod { - switch p.config.GitProvider { - case codebaseApi.GitProviderGithub: - // GitHub: username=token, password="" - return &http.BasicAuth{ - Username: p.config.Username, - Password: p.config.Token, - } - case codebaseApi.GitProviderGitlab: - // GitLab: username="oauth2", password=token - return &http.BasicAuth{ - Username: "oauth2", - Password: p.config.Token, - } - case codebaseApi.GitProviderBitbucket: - // Bitbucket: username=username, password=app_password - return &http.BasicAuth{ - Username: p.config.Username, - Password: p.config.Token, - } - default: - // Default to GitHub format - return &http.BasicAuth{ - Username: p.config.Username, - Password: p.config.Token, - } - } -} - -// Clone clones a repository to the specified destination. -func (p *GitProvider) Clone(ctx context.Context, repoURL, destination string) error { - log := ctrl.LoggerFrom(ctx).WithValues("repository", repoURL, "destination", destination) - log.Info("Cloning repository") - - auth, err := p.getAuth() - if err != nil { - return fmt.Errorf("failed to get authentication: %w", err) - } - - // Clone the repository (gets default branch) - cloneOptions := &git.CloneOptions{ - URL: repoURL, - Progress: os.Stdout, - Auth: auth, - } - - repo, err := git.PlainCloneContext(ctx, destination, false, cloneOptions) - if err != nil { - return fmt.Errorf("failed to clone repository: %w", remoteErr(err, repoURL)) - } - - log.Info("Repository cloned successfully, now fetching all branches and tags") - - // Fetch all refs (branches, tags, etc.) to get everything similar to --mirror - fetchOptions := &git.FetchOptions{ - RemoteName: "origin", - Auth: auth, - Progress: os.Stdout, - RefSpecs: []config.RefSpec{ - config.RefSpec("+refs/heads/*:refs/heads/*"), - config.RefSpec("+refs/tags/*:refs/tags/*"), - }, - } - - err = repo.FetchContext(ctx, fetchOptions) - if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) { - return fmt.Errorf("failed to fetch all branches and tags: %w", remoteErr(err, repoURL)) - } - - log.Info("All branches and tags fetched successfully") - - return nil -} - -// Commit commits changes in the working directory. -func (p *GitProvider) Commit(ctx context.Context, directory, message string, ops ...CommitOps) error { - log := ctrl.LoggerFrom(ctx).WithValues("directory", directory) - log.Info("Committing changes") - - // Apply options - option := &commitOps{ - allowEmptyCommit: false, - } - for _, applyOption := range ops { - applyOption(option) + return &http.BasicAuth{ + Username: p.tokenUsername(), + Password: p.config.Token, } - - repo, err := git.PlainOpen(directory) - if err != nil { - return fmt.Errorf("failed to open repository at %q: %w", directory, err) - } - - worktree, err := repo.Worktree() - if err != nil { - return fmt.Errorf("failed to get worktree: %w", err) - } - - // Add all changes - err = worktree.AddWithOptions(&git.AddOptions{ - All: true, - }) - if err != nil { - return fmt.Errorf("failed to add files to index: %w", err) - } - - // Check if there are changes to commit - if !option.allowEmptyCommit { - status, err := worktree.Status() - if err != nil { - return fmt.Errorf("failed to get status: %w", err) - } - - if status.IsClean() { - log.Info("Nothing to commit, working tree clean") - return nil - } - } - - // Commit changes - _, err = worktree.Commit(message, &git.CommitOptions{ - Author: &object.Signature{ - Name: defaultCommitName, - Email: defaultCommitEmail, - When: time.Now(), - }, - AllowEmptyCommits: option.allowEmptyCommit, - }) - if err != nil { - return fmt.Errorf("failed to commit: %w", err) - } - - log.Info("Changes committed successfully") - - return nil -} - -// Push pushes changes to the remote repository. -func (p *GitProvider) Push(ctx context.Context, directory string, refspecs ...string) error { - log := ctrl.LoggerFrom(ctx).WithValues("directory", directory) - log.Info("Pushing changes") - - repo, err := git.PlainOpen(directory) - if err != nil { - return fmt.Errorf("failed to open repository at %q: %w", directory, err) - } - - auth, err := p.getAuth() - if err != nil { - return fmt.Errorf("failed to get authentication: %w", err) - } - - pushOptions := &git.PushOptions{ - RemoteName: "origin", - Auth: auth, - Progress: os.Stdout, - } - - // Convert refspecs if provided - if len(refspecs) > 0 { - pushOptions.RefSpecs = make([]config.RefSpec, len(refspecs)) - for i, refspec := range refspecs { - pushOptions.RefSpecs[i] = config.RefSpec(refspec) - } - } - - err = repo.PushContext(ctx, pushOptions) - if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) { - return fmt.Errorf("failed to push: %w", remoteErr(err, originURL(repo))) - } - - log.Info("Changes pushed successfully") - - return nil -} - -// Checkout checks out a branch in the repository. -// If remote is true, fetches from remote first and only creates local branch if it doesn't exist remotely. -// If remote is false, checks out existing branch without fetching. -func (p *GitProvider) Checkout(ctx context.Context, directory, branchName string, remote bool) error { - log := ctrl.LoggerFrom(ctx).WithValues("directory", directory, "branch", branchName, "remote", remote) - log.Info("Checking out branch") - - repo, err := git.PlainOpen(directory) - if err != nil { - return fmt.Errorf("failed to open repository at %q: %w", directory, err) - } - - worktree, err := repo.Worktree() - if err != nil { - return fmt.Errorf("failed to get worktree: %w", err) - } - - createBranch := true - - if remote { - auth, err := p.getAuth() - if err != nil { - return fmt.Errorf("failed to get authentication: %w", err) - } - - fetchOptions := &git.FetchOptions{ - RefSpecs: []config.RefSpec{"refs/*:refs/*"}, - Auth: auth, - Progress: os.Stdout, - // The refspec has no force prefix, so without this a rebased or - // force-pushed upstream branch fails the fetch with ErrForceNeeded - // against a cached workdir. - Force: true, - } - - err = repo.FetchContext(ctx, fetchOptions) - if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) { - return fmt.Errorf("failed to fetch: %w", remoteErr(err, originURL(repo))) - } - - // The refspec above maps remote branches straight into local - // refs/heads, so that is the namespace that proves existence here. - // Checking refs/remotes/origin instead would miss any branch the - // fetch just materialized and collide on Create below. - _, err = repo.Reference(plumbing.NewBranchReferenceName(branchName), false) - if err == nil { - createBranch = false - } - } - - checkoutOptions := &git.CheckoutOptions{ - Branch: plumbing.NewBranchReferenceName(branchName), - Force: true, - Create: createBranch, - } - - err = worktree.Checkout(checkoutOptions) - if err != nil { - return fmt.Errorf("failed to checkout branch: %w", err) - } - - log.Info("Branch checked out successfully") - - return nil } // GetCurrentBranchName returns the name of the current branch. @@ -527,44 +293,6 @@ func (p *GitProvider) RemoveBranch(ctx context.Context, directory, branchName st return nil } -// CreateChildBranch creates a new branch from an existing branch. -func (p *GitProvider) CreateChildBranch(ctx context.Context, directory, parentBranch, newBranch string) error { - log := ctrl.LoggerFrom(ctx).WithValues("directory", directory, "parent", parentBranch, "newBranch", newBranch) - log.Info("Creating child branch") - - repo, err := git.PlainOpen(directory) - if err != nil { - return fmt.Errorf("failed to open repository at %q: %w", directory, err) - } - - worktree, err := repo.Worktree() - if err != nil { - return fmt.Errorf("failed to get worktree: %w", err) - } - - // Checkout parent branch first - err = worktree.Checkout(&git.CheckoutOptions{ - Branch: plumbing.NewBranchReferenceName(parentBranch), - Force: true, - }) - if err != nil { - return fmt.Errorf("failed to checkout parent branch: %w", err) - } - - // Create and checkout new branch - err = worktree.Checkout(&git.CheckoutOptions{ - Branch: plumbing.NewBranchReferenceName(newBranch), - Create: true, - }) - if err != nil { - return fmt.Errorf("failed to create child branch: %w", err) - } - - log.Info("Child branch created successfully") - - return nil -} - // Init initializes a new git repository. func (p *GitProvider) Init(ctx context.Context, directory string) error { log := ctrl.LoggerFrom(ctx).WithValues("directory", directory) @@ -609,54 +337,3 @@ func (p *GitProvider) AddRemoteLink(ctx context.Context, directory, remoteURL st return nil } - -// CheckoutRemoteBranch fetches from remote and checks out the specified branch. -// This is a convenience method that fetches and checks out a remote branch. -func (p *GitProvider) CheckoutRemoteBranch(ctx context.Context, directory, branchName string) error { - log := ctrl.LoggerFrom(ctx).WithValues("directory", directory, "branch", branchName) - log.Info("Checking out remote branch") - - repo, err := git.PlainOpen(directory) - if err != nil { - return fmt.Errorf("failed to open repository at %q: %w", directory, err) - } - - // Fetch from remote first - auth, err := p.getAuth() - if err != nil { - return fmt.Errorf("failed to get authentication: %w", err) - } - - fetchOptions := &git.FetchOptions{ - RemoteName: "origin", - RefSpecs: []config.RefSpec{"refs/*:refs/*"}, - Auth: auth, - Progress: os.Stdout, - Force: true, // Equivalent to --update-head-ok - } - - err = repo.FetchContext(ctx, fetchOptions) - if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) { - return fmt.Errorf("failed to fetch: %w", err) - } - - // Checkout the branch (expects it exists remotely) - worktree, err := repo.Worktree() - if err != nil { - return fmt.Errorf("failed to get worktree: %w", err) - } - - checkoutOptions := &git.CheckoutOptions{ - Branch: plumbing.NewBranchReferenceName(branchName), - Force: true, - } - - err = worktree.Checkout(checkoutOptions) - if err != nil { - return fmt.Errorf("failed to checkout branch: %w", err) - } - - log.Info("Remote branch checked out successfully") - - return nil -} diff --git a/pkg/git/provider_test.go b/pkg/git/provider_test.go index 141990e7..e4f11d4c 100644 --- a/pkg/git/provider_test.go +++ b/pkg/git/provider_test.go @@ -4,7 +4,9 @@ package v2 import ( "context" "os" + "os/exec" "path" + "strings" "testing" "time" @@ -19,6 +21,28 @@ import ( "github.com/epam/edp-codebase-operator/v2/pkg/platform" ) +func gitOutput(t *testing.T, dir string, args ...string) string { + t.Helper() + + out, err := exec.Command("git", append([]string{"-C", dir}, args...)...).CombinedOutput() + require.NoError(t, err, string(out)) + + return strings.TrimSpace(string(out)) +} + +func commitCount(t *testing.T, dir string) string { + t.Helper() + + return gitOutput(t, dir, "rev-list", "--all", "--count") +} + +func assertHeadCommitIdentity(t *testing.T, dir string) { + t.Helper() + + assert.Equal(t, defaultCommitName+" "+defaultCommitEmail+" "+defaultCommitName+" "+defaultCommitEmail, + gitOutput(t, dir, "log", "-1", "--format=%an %ae %cn %ce")) +} + func TestGitProvider_CheckPermissions(t *testing.T) { user := "user" pass := "pass" @@ -85,7 +109,7 @@ func TestGitProvider_ListRemoteBranches_EmptyRepo(t *testing.T) { assert.Empty(t, branches) } -func TestGitProvider_CreateChildBranch(t *testing.T) { +func TestCLIGitProvider_CreateChildBranch(t *testing.T) { tests := []struct { name string initRepo func(t *testing.T) string @@ -139,7 +163,7 @@ func TestGitProvider_CreateChildBranch(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - gp := NewGitProvider(Config{}) + gp := NewCLIGitProvider(Config{}) dir := tt.initRepo(t) err := gp.CreateChildBranch(context.Background(), dir, tt.parent, tt.child) @@ -215,7 +239,7 @@ func TestGitProvider_RemoveBranch(t *testing.T) { } } -func TestGitProvider_CommitChanges(t *testing.T) { +func TestCLIGitProvider_CommitChanges(t *testing.T) { tests := []struct { name string ops []CommitOps @@ -237,20 +261,8 @@ func TestGitProvider_CommitChanges(t *testing.T) { }, wantErr: require.NoError, checkRepo: func(t *testing.T, dir string) { - r, err := gogit.PlainOpen(dir) - require.NoError(t, err) - - commits, err := r.CommitObjects() - require.NoError(t, err) - - count := 0 - _ = commits.ForEach(func(*object.Commit) error { - count++ - - return nil - }) - - require.Equalf(t, 1, count, "expected 1 commits, got %d", count) + assert.Equal(t, "1", commitCount(t, dir)) + assertHeadCommitIdentity(t, dir) }, }, { @@ -264,20 +276,7 @@ func TestGitProvider_CommitChanges(t *testing.T) { }, wantErr: require.NoError, checkRepo: func(t *testing.T, dir string) { - r, err := gogit.PlainOpen(dir) - require.NoError(t, err) - - commits, err := r.CommitObjects() - require.NoError(t, err) - - count := 0 - _ = commits.ForEach(func(*object.Commit) error { - count++ - - return nil - }) - - require.Equalf(t, 0, count, "expected 0 commits, got %d", count) + assert.Equal(t, "0", commitCount(t, dir)) }, }, { @@ -294,27 +293,35 @@ func TestGitProvider_CommitChanges(t *testing.T) { }, wantErr: require.NoError, checkRepo: func(t *testing.T, dir string) { - r, err := gogit.PlainOpen(dir) - require.NoError(t, err) - - commits, err := r.CommitObjects() + assert.Equal(t, "1", commitCount(t, dir)) + assertHeadCommitIdentity(t, dir) + }, + }, + { + name: "should stage deletions", + initRepo: func(t *testing.T) string { + dir := t.TempDir() + _, err := gogit.PlainInit(dir, false) require.NoError(t, err) - count := 0 - _ = commits.ForEach(func(*object.Commit) error { - count++ + require.NoError(t, os.WriteFile(path.Join(dir, "old.txt"), []byte("old"), 0o600)) + require.NoError(t, NewCLIGitProvider(Config{}).Commit(context.Background(), dir, "seed")) + require.NoError(t, os.Remove(path.Join(dir, "old.txt"))) - return nil - }) - - require.Equalf(t, 1, count, "expected 1 commits, got %d", count) + return dir + }, + wantErr: require.NoError, + checkRepo: func(t *testing.T, dir string) { + assert.Equal(t, "2", commitCount(t, dir)) + assert.Empty(t, gitOutput(t, dir, "ls-tree", "-r", "--name-only", "HEAD")) + assert.Empty(t, gitOutput(t, dir, "status", "--porcelain")) }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - gp := NewGitProvider(Config{}) + gp := NewCLIGitProvider(Config{}) dir := tt.initRepo(t) err := gp.Commit(context.Background(), dir, "test commit message", tt.ops...) @@ -733,7 +740,7 @@ func TestGitProvider_GetCurrentBranchName(t *testing.T) { } } -func TestGitProvider_Checkout_LocalMode(t *testing.T) { +func TestCLIGitProvider_Checkout_LocalMode(t *testing.T) { tests := []struct { name string initRepo func(t *testing.T) string @@ -789,13 +796,13 @@ func TestGitProvider_Checkout_LocalMode(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - gp := NewGitProvider(Config{}) + gp := NewCLIGitProvider(Config{}) dir := tt.initRepo(t) err := gp.Checkout(context.Background(), dir, tt.branchName, tt.remote) if tt.wantErr { - require.Error(t, err) + require.ErrorContains(t, err, "failed to checkout branch") return } @@ -813,7 +820,7 @@ func TestGitProvider_Checkout_LocalMode(t *testing.T) { } } -func TestGitProvider_Clone_Errors(t *testing.T) { +func TestCLIGitProvider_Clone_Errors(t *testing.T) { tests := []struct { name string repoURL string @@ -828,13 +835,13 @@ func TestGitProvider_Clone_Errors(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - gp := NewGitProvider(Config{}) + gp := NewCLIGitProvider(Config{}) destination := t.TempDir() err := gp.Clone(context.Background(), tt.repoURL, destination) if tt.wantErr { - require.Error(t, err) + require.ErrorContains(t, err, "failed to clone repository") return } @@ -843,7 +850,7 @@ func TestGitProvider_Clone_Errors(t *testing.T) { } } -func TestGitProvider_Commit_ErrorCases(t *testing.T) { +func TestCLIGitProvider_Commit_ErrorCases(t *testing.T) { tests := []struct { name string initRepo func(t *testing.T) string @@ -862,13 +869,13 @@ func TestGitProvider_Commit_ErrorCases(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - gp := NewGitProvider(Config{}) + gp := NewCLIGitProvider(Config{}) dir := tt.initRepo(t) err := gp.Commit(context.Background(), dir, tt.message) if tt.wantErr { - require.Error(t, err) + require.ErrorContains(t, err, "failed to add files to index") return } @@ -877,7 +884,7 @@ func TestGitProvider_Commit_ErrorCases(t *testing.T) { } } -func TestGitProvider_Push_ErrorCases(t *testing.T) { +func TestCLIGitProvider_Push_ErrorCases(t *testing.T) { tests := []struct { name string initRepo func(t *testing.T) string @@ -896,13 +903,13 @@ func TestGitProvider_Push_ErrorCases(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - gp := NewGitProvider(Config{}) + gp := NewCLIGitProvider(Config{}) dir := tt.initRepo(t) err := gp.Push(context.Background(), dir, tt.refspec) if tt.wantErr { - require.Error(t, err) + require.ErrorContains(t, err, "failed to push") return } @@ -911,7 +918,7 @@ func TestGitProvider_Push_ErrorCases(t *testing.T) { } } -func TestGitProvider_CheckoutRemoteBranch_Errors(t *testing.T) { +func TestCLIGitProvider_CheckoutRemoteBranch_Errors(t *testing.T) { tests := []struct { name string initRepo func(t *testing.T) string @@ -930,13 +937,13 @@ func TestGitProvider_CheckoutRemoteBranch_Errors(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - gp := NewGitProvider(Config{}) + gp := NewCLIGitProvider(Config{}) dir := tt.initRepo(t) err := gp.CheckoutRemoteBranch(context.Background(), dir, tt.branchName) if tt.wantErr { - require.Error(t, err) + require.ErrorContains(t, err, "failed to fetch") return } @@ -945,7 +952,7 @@ func TestGitProvider_CheckoutRemoteBranch_Errors(t *testing.T) { } } -func TestGitProvider_CreateChildBranch_Errors(t *testing.T) { +func TestCLIGitProvider_CreateChildBranch_Errors(t *testing.T) { tests := []struct { name string initRepo func(t *testing.T) string @@ -1000,13 +1007,13 @@ func TestGitProvider_CreateChildBranch_Errors(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - gp := NewGitProvider(Config{}) + gp := NewCLIGitProvider(Config{}) dir := tt.initRepo(t) err := gp.CreateChildBranch(context.Background(), dir, tt.parentName, tt.childName) if tt.wantErr { - require.Error(t, err) + require.ErrorContains(t, err, "failed to checkout parent branch") return } @@ -1049,7 +1056,7 @@ func TestGitProvider_RemoveBranch_Errors(t *testing.T) { } } -func TestGitProvider_Clone_Basic(t *testing.T) { +func TestCLIGitProvider_Clone_Basic(t *testing.T) { tests := []struct { name string setupEnv func(t *testing.T) (string, string) @@ -1067,13 +1074,13 @@ func TestGitProvider_Clone_Basic(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - gp := NewGitProvider(Config{}) + gp := NewCLIGitProvider(Config{}) targetDir, repoURL := tt.setupEnv(t) err := gp.Clone(context.Background(), repoURL, targetDir) if tt.wantErr { - require.Error(t, err) + require.ErrorContains(t, err, "failed to clone repository") return } @@ -1086,7 +1093,7 @@ func TestGitProvider_Clone_Basic(t *testing.T) { } } -func TestGitProvider_Push_NoRemote(t *testing.T) { +func TestCLIGitProvider_Push_NoRemote(t *testing.T) { tests := []struct { name string initRepo func(t *testing.T) string @@ -1130,13 +1137,13 @@ func TestGitProvider_Push_NoRemote(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - gp := NewGitProvider(Config{}) + gp := NewCLIGitProvider(Config{}) dir := tt.initRepo(t) err := gp.Push(context.Background(), dir, tt.refspec) if tt.wantErr { - require.Error(t, err) + require.ErrorContains(t, err, "failed to push") return } @@ -1145,7 +1152,7 @@ func TestGitProvider_Push_NoRemote(t *testing.T) { } } -func TestGitProvider_CheckoutRemoteBranch_NoRemote(t *testing.T) { +func TestCLIGitProvider_CheckoutRemoteBranch_NoRemote(t *testing.T) { tests := []struct { name string initRepo func(t *testing.T) string @@ -1189,13 +1196,13 @@ func TestGitProvider_CheckoutRemoteBranch_NoRemote(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - gp := NewGitProvider(Config{}) + gp := NewCLIGitProvider(Config{}) dir := tt.initRepo(t) err := gp.CheckoutRemoteBranch(context.Background(), dir, tt.branchName) if tt.wantErr { - require.Error(t, err) + require.ErrorContains(t, err, "failed to fetch") return } @@ -1235,3 +1242,34 @@ func TestSshTarget(t *testing.T) { }) } } + +func TestCLIGitProvider_Commit_RunsNoHooks(t *testing.T) { + dir := t.TempDir() + _, err := gogit.PlainInit(dir, false) + require.NoError(t, err) + + require.NoError(t, os.WriteFile(path.Join(dir, "a.txt"), []byte("a"), 0o600)) + + for _, hook := range []string{"pre-commit", "commit-msg"} { + hookPath := path.Join(dir, ".git", "hooks", hook) + require.NoError(t, os.MkdirAll(path.Dir(hookPath), 0o700)) + //nolint:gosec // the hook must be executable to prove it is not run + require.NoError(t, os.WriteFile(hookPath, []byte("#!/bin/sh\nexit 1\n"), 0o700)) + } + + require.NoError(t, NewCLIGitProvider(Config{}).Commit(context.Background(), dir, "msg")) + assert.Equal(t, "1", commitCount(t, dir)) +} + +func TestCLIGitProvider_Checkout_ExistingBranchWithoutRemote(t *testing.T) { + dir := t.TempDir() + _, err := gogit.PlainInit(dir, false) + require.NoError(t, err) + + gp := NewCLIGitProvider(Config{}) + require.NoError(t, gp.Commit(context.Background(), dir, "seed", CommitAllowEmpty())) + require.NoError(t, gp.Checkout(context.Background(), dir, "feature", false)) + + err = gp.Checkout(context.Background(), dir, "feature", false) + require.ErrorContains(t, err, "failed to checkout branch") +} diff --git a/pkg/sshhostkey/hostkey.go b/pkg/sshhostkey/hostkey.go index 963c62e9..6666b59d 100644 --- a/pkg/sshhostkey/hostkey.go +++ b/pkg/sshhostkey/hostkey.go @@ -19,9 +19,9 @@ import ( "golang.org/x/crypto/ssh/knownhosts" ) -// knownHostsEnvVar is go-git's own lookup variable, reused so that the go-git -// code paths and the raw golang.org/x/crypto/ssh code paths always agree on -// which file is authoritative. +// knownHostsEnvVar is go-git's own lookup variable. go-git, the ssh binary +// (UserKnownHostsFile) and the raw golang.org/x/crypto/ssh code paths all read +// the file it names. const knownHostsEnvVar = "SSH_KNOWN_HOSTS" // ClientConfig returns the host key callback and the host key algorithms to set @@ -51,9 +51,14 @@ func HostPort(host string, port int) string { return net.JoinHostPort(host, strconv.Itoa(port)) } -// Source is for error messages only; the file itself is opened by go-git. +// KnownHostsFile returns the path in SSH_KNOWN_HOSTS, or "" when it is unset. +func KnownHostsFile() string { + return os.Getenv(knownHostsEnvVar) +} + +// Source is for error messages only; the file itself is opened by go-git or ssh. func Source() string { - if path := os.Getenv(knownHostsEnvVar); path != "" { + if path := KnownHostsFile(); path != "" { return path }