Skip to content
Closed
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
6 changes: 4 additions & 2 deletions cmd/entire/cli/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (

"github.com/charmbracelet/huh"
"github.com/entireio/cli/cmd/entire/cli/checkpoint"
"github.com/entireio/cli/cmd/entire/cli/paths"
"github.com/entireio/cli/cmd/entire/cli/session"
"github.com/entireio/cli/cmd/entire/cli/strategy"

Expand Down Expand Up @@ -319,7 +320,8 @@ func checkDisconnectedMetadata(cmd *cobra.Command, force bool) error {
}

ctx := cmd.Context()
disconnected, err := strategy.IsMetadataDisconnected(ctx, repo)
originRef := plumbing.NewRemoteReferenceName("origin", paths.MetadataBranchName)
disconnected, err := strategy.IsMetadataDisconnected(ctx, repo, originRef)
if err != nil {
return fmt.Errorf("could not check metadata branch state: %w", err)
}
Expand Down Expand Up @@ -357,7 +359,7 @@ func checkDisconnectedMetadata(cmd *cobra.Command, force bool) error {
}
}

if fixErr := strategy.ReconcileDisconnectedMetadataBranch(ctx, repo, cmd.ErrOrStderr()); fixErr != nil {
if fixErr := strategy.ReconcileDisconnectedMetadataBranch(ctx, repo, originRef, cmd.ErrOrStderr()); fixErr != nil {
return fmt.Errorf("failed to reconcile metadata branches: %w", fixErr)
}

Expand Down
211 changes: 211 additions & 0 deletions cmd/entire/cli/fetch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
package cli

import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"os"
"os/exec"
"strings"

"github.com/entireio/cli/cmd/entire/cli/logging"
"github.com/entireio/cli/cmd/entire/cli/paths"
"github.com/entireio/cli/cmd/entire/cli/strategy"
"github.com/spf13/cobra"
)

// Git remote protocol identifiers used for auth detection.
const (
fetchProtocolSSH = "ssh"
fetchProtocolHTTPS = "https"
)

func newFetchCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "fetch",
Hidden: true,
Short: "Fetch the remote checkpoint tip",
RunE: func(cmd *cobra.Command, _ []string) error {
ctx := cmd.Context()

if _, err := paths.WorktreeRoot(ctx); err != nil {
return errors.New("not a git repository")
}

logging.SetLogLevelGetter(GetLogLevel)
if err := logging.Init(ctx, ""); err == nil {
defer logging.Close()
}

return runFetch(ctx, cmd.OutOrStdout(), cmd.ErrOrStderr())
},
}

return cmd
}

func runFetch(ctx context.Context, w, errW io.Writer) error {
// Resolve the checkpoint remote URL from settings.
checkpointURL, configured, err := strategy.ResolveCheckpointRemoteURL(ctx)
if err != nil {
return fmt.Errorf("failed to resolve checkpoint remote: %w", err)
}

// Determine the fetch target: checkpoint URL if configured, otherwise "origin".
target := "origin"
if configured && checkpointURL != "" {
target = checkpointURL
}

protocol := resolveProtocolForTarget(ctx, target)
authMethod := detectAuthMethod(ctx, protocol)
remoteInfo := resolveRemoteInfo(ctx, target)

logging.Debug(ctx, "fetch: auth detection",
slog.String("auth", authMethod),
slog.String("domain", remoteInfo.domain),
slog.String("repo", remoteInfo.ownerRepo),
slog.String("protocol", protocol),
)

if isDebugMode() {
fmt.Fprintf(errW, "[entire] fetch: remote=%s/%s protocol=%s auth=%s\n",
remoteInfo.domain, remoteInfo.ownerRepo, protocol, authMethod)
}

// Fetch the metadata branch.
branchName := paths.MetadataBranchName
logging.Debug(ctx, "fetch: fetching checkpoint tip",
slog.String("branch", branchName),
slog.String("domain", remoteInfo.domain),
slog.String("repo", remoteInfo.ownerRepo),
slog.String("protocol", protocol),
)

if err := strategy.FetchMetadataBranch(ctx, target); err != nil {
fmt.Fprintf(errW, "[entire] Failed to fetch %s: %v\n", branchName, err)
return NewSilentError(err)
}

fmt.Fprintf(w, "[entire] Fetched %s\n", branchName)
return nil
}

// detectAuthMethod determines how git will authenticate for the given target.
// Returns a human-readable description for debug logging.
func detectAuthMethod(ctx context.Context, protocol string) string {
// Check if ENTIRE_CHECKPOINT_TOKEN overrides auth.
if token := strings.TrimSpace(os.Getenv(strategy.CheckpointTokenEnvVar)); token != "" {
if protocol == fetchProtocolSSH {
return "ENTIRE_CHECKPOINT_TOKEN set (ignored: remote uses SSH)"
}
return "ENTIRE_CHECKPOINT_TOKEN"
}

switch protocol {
case fetchProtocolSSH:
if hasSSHAgent() {
return "SSH agent"
}
return "SSH (no agent detected)"
case fetchProtocolHTTPS:
if helper := gitCredentialHelper(ctx); helper != "" {
return fmt.Sprintf("Git credential helper (%s)", helper)
}
return "no auth detected"
default:
return "no auth detected"
}
}

// resolveProtocolForTarget returns "ssh", "https", or "" for the given target.
func resolveProtocolForTarget(ctx context.Context, target string) string {
if isFetchURL(target) {
if strings.HasPrefix(target, "https://") {
return fetchProtocolHTTPS
}
if strings.Contains(target, "@") {
return fetchProtocolSSH
}
return ""
}
// Remote name — resolve URL.
rawURL, err := getRemoteURLForFetch(ctx, target)
if err != nil {
return ""
}
if strings.HasPrefix(rawURL, "https://") || strings.HasPrefix(rawURL, "http://") {
return fetchProtocolHTTPS
}
// SCP-style SSH: git@host:path or contains "@"
if strings.Contains(rawURL, "@") {
return fetchProtocolSSH
}
return ""
}

// hasSSHAgent checks whether an SSH agent is available via SSH_AUTH_SOCK.
func hasSSHAgent() bool {
return os.Getenv("SSH_AUTH_SOCK") != ""
}

// gitCredentialHelper returns the configured git credential helper, if any.
func gitCredentialHelper(ctx context.Context) string {
cmd := exec.CommandContext(ctx, "git", "config", "credential.helper")
output, err := cmd.Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(output))
}

// getRemoteURLForFetch resolves a remote name to its URL.
func getRemoteURLForFetch(ctx context.Context, remoteName string) (string, error) {
cmd := exec.CommandContext(ctx, "git", "remote", "get-url", remoteName)
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("remote %q not found", remoteName)
}
return strings.TrimSpace(string(output)), nil
}

// fetchRemoteInfo holds parsed remote components for logging.
type fetchRemoteInfo struct {
domain string // e.g., "github.com"
ownerRepo string // e.g., "org/my-repo"
}

// resolveRemoteInfo parses the target into domain and owner/repo for display.
// If the target is a remote name, resolves it to a URL first.
func resolveRemoteInfo(ctx context.Context, target string) fetchRemoteInfo {
rawURL := target
if !isFetchURL(target) {
var err error
rawURL, err = getRemoteURLForFetch(ctx, target)
if err != nil {
return fetchRemoteInfo{domain: target}
}
}

host, owner, repo, err := strategy.ParseRemoteURL(rawURL)
if err != nil {
return fetchRemoteInfo{domain: target}
}
return fetchRemoteInfo{domain: host, ownerRepo: owner + "/" + repo}
}

// isDebugMode returns true if debug-level logging is enabled.
func isDebugMode() bool {
level := strings.ToUpper(os.Getenv(logging.LogLevelEnvVar))
if level != "" {
return level == "DEBUG"
}
return strings.EqualFold(GetLogLevel(), "DEBUG")
}

// isFetchURL returns true if the target looks like a URL rather than a git remote name.
func isFetchURL(target string) bool {
return strings.Contains(target, "://") || strings.Contains(target, "@")
}
98 changes: 98 additions & 0 deletions cmd/entire/cli/fetch_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package cli

import (
"context"
"testing"

"github.com/stretchr/testify/assert"
)

func TestDetectAuthMethod_TokenOverride(t *testing.T) {
t.Setenv("ENTIRE_CHECKPOINT_TOKEN", "ghp_abc123")
method := detectAuthMethod(context.Background(), fetchProtocolHTTPS)
assert.Equal(t, "ENTIRE_CHECKPOINT_TOKEN", method)
}

func TestDetectAuthMethod_TokenIgnoredForSSH(t *testing.T) {
t.Setenv("ENTIRE_CHECKPOINT_TOKEN", "ghp_abc123")
method := detectAuthMethod(context.Background(), fetchProtocolSSH)
assert.Equal(t, "ENTIRE_CHECKPOINT_TOKEN set (ignored: remote uses SSH)", method)
}

func TestDetectAuthMethod_SSHWithAgent(t *testing.T) {
t.Setenv("ENTIRE_CHECKPOINT_TOKEN", "")
t.Setenv("SSH_AUTH_SOCK", "/tmp/ssh-agent.sock")
method := detectAuthMethod(context.Background(), fetchProtocolSSH)
assert.Equal(t, "SSH agent", method)
}

func TestDetectAuthMethod_SSHNoAgent(t *testing.T) {
t.Setenv("ENTIRE_CHECKPOINT_TOKEN", "")
t.Setenv("SSH_AUTH_SOCK", "")
method := detectAuthMethod(context.Background(), fetchProtocolSSH)
assert.Equal(t, "SSH (no agent detected)", method)
}

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

protocol := resolveProtocolForTarget(context.Background(), "https://github.com/org/repo.git")
assert.Equal(t, fetchProtocolHTTPS, protocol)
}

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

protocol := resolveProtocolForTarget(context.Background(), "git@github.com:org/repo.git")
assert.Equal(t, fetchProtocolSSH, protocol)
}

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

// A non-existent remote name should return empty protocol.
protocol := resolveProtocolForTarget(context.Background(), "nonexistent-remote")
assert.Empty(t, protocol)
}

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

info := resolveRemoteInfo(context.Background(), "https://github.com/myorg/my-repo.git")
assert.Equal(t, "github.com", info.domain)
assert.Equal(t, "myorg/my-repo", info.ownerRepo)
}

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

info := resolveRemoteInfo(context.Background(), "git@github.com:myorg/my-repo.git")
assert.Equal(t, "github.com", info.domain)
assert.Equal(t, "myorg/my-repo", info.ownerRepo)
}

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

info := resolveRemoteInfo(context.Background(), "nonexistent-remote")
assert.Equal(t, "nonexistent-remote", info.domain)
assert.Empty(t, info.ownerRepo)
}

func TestIsDebugMode(t *testing.T) {
t.Setenv("ENTIRE_LOG_LEVEL", "DEBUG")
assert.True(t, isDebugMode())
}

func TestIsDebugMode_NotDebug(t *testing.T) {
t.Setenv("ENTIRE_LOG_LEVEL", "INFO")
assert.False(t, isDebugMode())
}

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

assert.True(t, isFetchURL("https://github.com/org/repo.git"))
assert.True(t, isFetchURL("git@github.com:org/repo.git"))
assert.False(t, isFetchURL("origin"))
}
44 changes: 6 additions & 38 deletions cmd/entire/cli/git_operations.go
Original file line number Diff line number Diff line change
Expand Up @@ -370,44 +370,14 @@ func FetchAndCheckoutRemoteBranch(ctx context.Context, branchName string) error
return CheckoutBranch(ctx, branchName)
}

// 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.
// FetchMetadataBranch fetches the entire/checkpoints/v1 branch from origin and advances the
// local branch only on a fast-forward (or when the branch is missing).
// Uses git CLI instead of go-git for fetch because go-git doesn't use credential helpers,
// which breaks HTTPS URLs that require authentication.
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)

fetchCmd := strategy.CheckpointGitCommand(ctx, "origin", "fetch", "origin", refSpec)
if output, err := fetchCmd.CombinedOutput(); err != nil {
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)
if err := strategy.FetchMetadataBranch(ctx, "origin"); err != nil {
return fmt.Errorf("fetch metadata branch from origin: %w", err)
}

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

// Get the remote branch reference
remoteRef, err := repo.Reference(plumbing.NewRemoteReferenceName("origin", branchName), true)
if err != nil {
return fmt.Errorf("branch '%s' not found on origin: %w", branchName, err)
}

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

return nil
}

Expand Down Expand Up @@ -444,10 +414,8 @@ func FetchMetadataTreeOnly(ctx context.Context) error {
return fmt.Errorf("branch '%s' not found on origin: %w", branchName, err)
}

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

return nil
Expand Down
Loading
Loading