Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
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
15 changes: 10 additions & 5 deletions cmd/entire/cli/clean.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,17 +33,16 @@ This command finds and removes orphaned data from any strategy:
reference them.

Checkpoint metadata (entire/checkpoints/v1 branch)
Checkpoints are permanent (condensed session history) and are
never considered orphaned.
Orphaned checkpoint entries (e.g., entries whose linked commit no longer
exists) may be removed from the branch. The entire/checkpoints/v1 branch
itself is preserved.

Temporary files (.entire/tmp/)
Cached transcripts and other temporary data. Safe to delete when no
active sessions are using them.

Default: shows a preview of items that would be deleted.
With --force, actually deletes the orphaned items.

The entire/checkpoints/v1 branch itself is never deleted.`,
With --force, actually deletes the orphaned items.`,
RunE: func(cmd *cobra.Command, _ []string) error {
return runClean(cmd.Context(), cmd.OutOrStdout(), forceFlag)
},
Expand All @@ -55,6 +54,12 @@ The entire/checkpoints/v1 branch itself is never deleted.`,
}

func runClean(ctx context.Context, w io.Writer, force bool) error {
// Verify we're in a git repository before initializing logging,
// otherwise logging.Init falls back to cwd and creates .entire/logs/ outside a repo.
if _, err := paths.WorktreeRoot(ctx); err != nil {
return fmt.Errorf("not a git repository: %w", err)
}

// Initialize logging so structured logs go to .entire/logs/ instead of stderr.
// Error is non-fatal: if logging init fails, logs go to stderr (acceptable fallback).
logging.SetLogLevelGetter(GetLogLevel)
Expand Down
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