Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 39 additions & 27 deletions cmd/entire/cli/git_operations.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ import (
"strings"
"time"

"github.com/entireio/cli/cmd/entire/cli/gitauth"
"github.com/entireio/cli/cmd/entire/cli/paths"
"github.com/entireio/cli/cmd/entire/cli/strategy"

"github.com/go-git/go-git/v6"
"github.com/go-git/go-git/v6/config"
"github.com/go-git/go-git/v6/plumbing"
)

Expand Down Expand Up @@ -310,31 +312,36 @@ func ValidateBranchName(ctx context.Context, branchName string) error {
}

// FetchAndCheckoutRemoteBranch fetches a branch from origin and creates a local tracking branch.
// Uses git CLI instead of go-git for fetch because go-git doesn't use credential helpers,
// which breaks HTTPS URLs that require authentication.
// Uses go-git with credential helper / SSH agent auth.
func FetchAndCheckoutRemoteBranch(ctx context.Context, branchName string) error {
// Validate branch name before using in shell command (branchName comes from user CLI input)
if err := ValidateBranchName(ctx, branchName); err != nil {
return err
}

// Use git CLI for fetch (go-git's fetch can be tricky with auth)
ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()

refSpec := fmt.Sprintf("+refs/heads/%s:refs/remotes/origin/%s", branchName, branchName)
repo, err := openRepository(ctx)
if err != nil {
return fmt.Errorf("failed to open repository: %w", err)
}

remoteURL := gitauth.RemoteURL(repo, "origin")
auth := gitauth.ResolveAuth(ctx, remoteURL)

fetchCmd := exec.CommandContext(ctx, "git", "fetch", "origin", refSpec)
if output, err := fetchCmd.CombinedOutput(); err != nil {
refSpec := config.RefSpec(fmt.Sprintf("+refs/heads/%s:refs/remotes/origin/%s", branchName, branchName))
err = repo.FetchContext(ctx, &git.FetchOptions{
RemoteName: "origin",
RefSpecs: []config.RefSpec{refSpec},
Auth: auth,
Tags: git.NoTags,
Force: true,
})
if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
if ctx.Err() == context.DeadlineExceeded {
return errors.New("fetch timed out after 2 minutes")
}
return fmt.Errorf("failed to fetch branch from origin: %s: %w", strings.TrimSpace(string(output)), err)
}

repo, err := openRepository(ctx)
if err != nil {
return fmt.Errorf("failed to open repository: %w", err)
return fmt.Errorf("failed to fetch branch from origin: %w", err)
}

// Get the remote branch reference
Expand All @@ -345,8 +352,7 @@ func FetchAndCheckoutRemoteBranch(ctx context.Context, branchName string) error

// Create local branch pointing to the same commit
localRef := plumbing.NewHashReference(plumbing.NewBranchReferenceName(branchName), remoteRef.Hash())
err = repo.Storer.SetReference(localRef)
if err != nil {
if err := repo.Storer.SetReference(localRef); err != nil {
return fmt.Errorf("failed to create local branch: %w", err)
}

Expand All @@ -356,28 +362,34 @@ func FetchAndCheckoutRemoteBranch(ctx context.Context, branchName string) error

// FetchMetadataBranch fetches the entire/checkpoints/v1 branch from origin and creates/updates the local branch.
// This is used when the metadata branch exists on remote but not locally.
// Uses git CLI instead of go-git for fetch because go-git doesn't use credential helpers,
// which breaks HTTPS URLs that require authentication.
// Uses go-git with credential helper / SSH agent auth.
func FetchMetadataBranch(ctx context.Context) error {
branchName := paths.MetadataBranchName

// Use git CLI for fetch (go-git's fetch can be tricky with auth)
ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()

refSpec := fmt.Sprintf("+refs/heads/%s:refs/remotes/origin/%s", branchName, branchName)
repo, err := openRepository(ctx)
if err != nil {
return fmt.Errorf("failed to open repository: %w", err)
}

remoteURL := gitauth.RemoteURL(repo, "origin")
auth := gitauth.ResolveAuth(ctx, remoteURL)

fetchCmd := exec.CommandContext(ctx, "git", "fetch", "origin", refSpec)
if output, err := fetchCmd.CombinedOutput(); err != nil {
refSpec := config.RefSpec(fmt.Sprintf("+refs/heads/%s:refs/remotes/origin/%s", branchName, branchName))
err = repo.FetchContext(ctx, &git.FetchOptions{
RemoteName: "origin",
RefSpecs: []config.RefSpec{refSpec},
Auth: auth,
Tags: git.NoTags,
Force: true,
})
if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
if ctx.Err() == context.DeadlineExceeded {
return errors.New("fetch timed out after 2 minutes")
}
return fmt.Errorf("failed to fetch %s from origin: %s: %w", branchName, strings.TrimSpace(string(output)), err)
}

repo, err := openRepository(ctx)
if err != nil {
return fmt.Errorf("failed to open repository: %w", err)
return fmt.Errorf("failed to fetch %s from origin: %w", branchName, err)
}

// Get the remote branch reference
Expand Down
140 changes: 140 additions & 0 deletions cmd/entire/cli/gitauth/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// Package gitauth resolves transport.AuthMethod for go-git operations.
//
// It checks for git credential helpers (for HTTPS) and SSH agent (for SSH),
// so that go-git fetch/push operations can authenticate without shelling out
// to the git CLI.
package gitauth

import (
"bytes"
"context"
"fmt"
"net/url"
"os"
"os/exec"
"strings"
"time"

git "github.com/go-git/go-git/v6"
"github.com/go-git/go-git/v6/plumbing/transport"
githttp "github.com/go-git/go-git/v6/plumbing/transport/http"
gitssh "github.com/go-git/go-git/v6/plumbing/transport/ssh"
)

// credentialHelperTimeout is the max time to wait for a credential helper.
const credentialHelperTimeout = 5 * time.Second

// ResolveAuth returns a transport.AuthMethod suitable for the given remote URL.
//
// For HTTPS URLs it attempts to use the git credential helper. For SSH URLs
// (including SCP format) it uses the SSH agent. Returns nil if no auth can be
// resolved, which allows unauthenticated access (e.g. public repos).
func ResolveAuth(ctx context.Context, remoteURL string) transport.AuthMethod { //nolint:ireturn // must return interface for go-git FetchOptions.Auth
if remoteURL == "" {
return nil
}

if IsSSHURL(remoteURL) {
return resolveSSHAuth()
}

// HTTPS (or other non-SSH)
return resolveHTTPAuth(ctx, remoteURL)
}

// IsSSHURL returns true if the URL uses SSH protocol.
// Supports SCP format (git@host:path) and ssh:// URLs.
func IsSSHURL(rawURL string) bool {
// SCP format: git@github.com:org/repo.git
// Has ":" but no "://" scheme separator.
if strings.Contains(rawURL, ":") && !strings.Contains(rawURL, "://") {
return true
}
// ssh:// protocol
return strings.HasPrefix(rawURL, "ssh://")
}

// resolveHTTPAuth runs `git credential fill` to obtain HTTPS credentials.
func resolveHTTPAuth(ctx context.Context, remoteURL string) transport.AuthMethod { //nolint:ireturn // returns *githttp.BasicAuth or nil
u, err := url.Parse(remoteURL)
if err != nil {
return nil
}

protocol := u.Scheme
host := u.Host // includes port if present
if protocol == "" || host == "" {
return nil
}

ctx, cancel := context.WithTimeout(ctx, credentialHelperTimeout)
defer cancel()

input := fmt.Sprintf("protocol=%s\nhost=%s\n\n", protocol, host)

cmd := exec.CommandContext(ctx, "git", "credential", "fill")
cmd.Stdin = strings.NewReader(input)
cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")

var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr

if err := cmd.Run(); err != nil {
return nil
}

username, password := parseCredentialOutput(stdout.String())
if username == "" && password == "" {
return nil
}

return &githttp.BasicAuth{
Username: username,
Password: password,
}
}

// parseCredentialOutput extracts username and password from `git credential fill` output.
func parseCredentialOutput(output string) (string, string) {
var username, password string
for line := range strings.SplitSeq(output, "\n") {
line = strings.TrimSpace(line)
if k, v, ok := strings.Cut(line, "="); ok {
switch k {
case "username":
username = v
case "password":
password = v
}
}
}
return username, password
}

// RemoteURL returns the first configured URL for the named remote.
// Returns empty string if the remote doesn't exist or has no URLs.
func RemoteURL(repo *git.Repository, remoteName string) string {
remote, err := repo.Remote(remoteName)
if err != nil {
return ""
}
cfg := remote.Config()
if len(cfg.URLs) == 0 {
return ""
}
return cfg.URLs[0]
}

// resolveSSHAuth returns an SSH agent auth method if an agent is available.
func resolveSSHAuth() transport.AuthMethod { //nolint:ireturn // returns *gitssh.PublicKeysCallback or nil
if os.Getenv("SSH_AUTH_SOCK") == "" {
return nil
}

auth, err := gitssh.NewSSHAgentAuth("git")
if err != nil {
return nil
}
return auth
}
95 changes: 95 additions & 0 deletions cmd/entire/cli/gitauth/auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package gitauth

import (
"context"
"testing"
)

func TestIsSSHURL(t *testing.T) {
t.Parallel()

tests := []struct {
name string
url string
want bool
}{
{"SCP format", "git@github.com:org/repo.git", true},
{"SSH protocol", "ssh://git@github.com/org/repo.git", true},
{"HTTPS", "https://github.com/org/repo.git", false},
{"HTTP", "http://github.com/org/repo.git", false},
{"empty", "", false},
{"SCP with port", "git@github.com:22:org/repo.git", true},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := IsSSHURL(tt.url); got != tt.want {
t.Errorf("IsSSHURL(%q) = %v, want %v", tt.url, got, tt.want)
}
})
}
}

func TestParseCredentialOutput(t *testing.T) {
t.Parallel()

tests := []struct {
name string
output string
wantUsername string
wantPassword string
}{
{
name: "standard output",
output: "protocol=https\nhost=github.com\nusername=token\npassword=ghp_xxx\n",
wantUsername: "token",
wantPassword: "ghp_xxx",
},
{
name: "empty output",
output: "",
wantUsername: "",
wantPassword: "",
},
{
name: "no credentials",
output: "protocol=https\nhost=github.com\n",
wantUsername: "",
wantPassword: "",
},
{
name: "only username",
output: "username=myuser\n",
wantUsername: "myuser",
wantPassword: "",
},
{
name: "password with equals sign",
output: "username=user\npassword=abc=def\n",
wantUsername: "user",
wantPassword: "abc=def",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
gotUser, gotPass := parseCredentialOutput(tt.output)
if gotUser != tt.wantUsername {
t.Errorf("username = %q, want %q", gotUser, tt.wantUsername)
}
if gotPass != tt.wantPassword {
t.Errorf("password = %q, want %q", gotPass, tt.wantPassword)
}
})
}
}

func TestResolveAuth_EmptyURL(t *testing.T) {
t.Parallel()
auth := ResolveAuth(context.Background(), "")
if auth != nil {
t.Error("expected nil auth for empty URL")
}
}
Loading
Loading