From 89a5f07bb7fa1f99bd67bbf159b36215400b3c63 Mon Sep 17 00:00:00 2001 From: Paulo Gomes Date: Tue, 17 Mar 2026 17:45:16 +0000 Subject: [PATCH 1/2] Replace git CLI fetch with go-git using credential helper and SSH agent auth Add gitauth package that resolves transport.AuthMethod for go-git by checking git credential helpers (HTTPS) and SSH agent (SSH URLs). Replace all exec.CommandContext 'git fetch' calls with repo.FetchContext/remote.FetchContext across git_operations.go, push_common.go, checkpoint_remote.go, and trail_cmd.go. Assisted-By: Claude Opus 4.6 (1M context) Entire-Checkpoint: b2dacff4eb36 Signed-off-by: Paulo Gomes --- cmd/entire/cli/git_operations.go | 66 +++++---- cmd/entire/cli/gitauth/auth.go | 140 +++++++++++++++++++ cmd/entire/cli/gitauth/auth_test.go | 95 +++++++++++++ cmd/entire/cli/strategy/checkpoint_remote.go | 26 +++- cmd/entire/cli/strategy/push_common.go | 49 +++++-- cmd/entire/cli/trail_cmd.go | 26 ++-- 6 files changed, 352 insertions(+), 50 deletions(-) create mode 100644 cmd/entire/cli/gitauth/auth.go create mode 100644 cmd/entire/cli/gitauth/auth_test.go diff --git a/cmd/entire/cli/git_operations.go b/cmd/entire/cli/git_operations.go index cec82c1c1..add9a2332 100644 --- a/cmd/entire/cli/git_operations.go +++ b/cmd/entire/cli/git_operations.go @@ -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" ) @@ -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 @@ -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) } @@ -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 diff --git a/cmd/entire/cli/gitauth/auth.go b/cmd/entire/cli/gitauth/auth.go new file mode 100644 index 000000000..81f3f866e --- /dev/null +++ b/cmd/entire/cli/gitauth/auth.go @@ -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 +} diff --git a/cmd/entire/cli/gitauth/auth_test.go b/cmd/entire/cli/gitauth/auth_test.go new file mode 100644 index 000000000..c94f3eb7c --- /dev/null +++ b/cmd/entire/cli/gitauth/auth_test.go @@ -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") + } +} diff --git a/cmd/entire/cli/strategy/checkpoint_remote.go b/cmd/entire/cli/strategy/checkpoint_remote.go index a147fc757..af2b01ac9 100644 --- a/cmd/entire/cli/strategy/checkpoint_remote.go +++ b/cmd/entire/cli/strategy/checkpoint_remote.go @@ -5,15 +5,19 @@ import ( "fmt" "log/slog" "net/url" - "os" "os/exec" "strings" "time" + "errors" + + "github.com/entireio/cli/cmd/entire/cli/gitauth" "github.com/entireio/cli/cmd/entire/cli/logging" "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/entireio/cli/cmd/entire/cli/settings" + "github.com/go-git/go-git/v6" + gitconfig "github.com/go-git/go-git/v6/config" "github.com/go-git/go-git/v6/plumbing" ) @@ -292,12 +296,20 @@ func fetchMetadataBranchIfMissing(ctx context.Context, remoteURL string) error { tmpRef := "refs/entire-fetch-tmp/" + branchName refSpec := fmt.Sprintf("+refs/heads/%s:%s", branchName, tmpRef) - fetchCmd := exec.CommandContext(fetchCtx, "git", "fetch", "--no-tags", remoteURL, refSpec) - fetchCmd.Stdin = nil - fetchCmd.Env = append(os.Environ(), - "GIT_TERMINAL_PROMPT=0", // Prevent interactive auth prompts - ) - if err := fetchCmd.Run(); err != nil { + + auth := gitauth.ResolveAuth(fetchCtx, remoteURL) + remote := git.NewRemote(repo.Storer, &gitconfig.RemoteConfig{ + Name: "anonymous", + URLs: []string{remoteURL}, + }) + fetchErr := remote.FetchContext(fetchCtx, &git.FetchOptions{ + RemoteName: "anonymous", + RefSpecs: []gitconfig.RefSpec{gitconfig.RefSpec(refSpec)}, + Auth: auth, + Tags: git.NoTags, + Force: true, + }) + if fetchErr != nil && !errors.Is(fetchErr, git.NoErrAlreadyUpToDate) { // Fetch failed - remote may be unreachable or branch doesn't exist there yet. // Not fatal: push will create it on the remote when it succeeds. return nil diff --git a/cmd/entire/cli/strategy/push_common.go b/cmd/entire/cli/strategy/push_common.go index b6fbea2dd..91835fc74 100644 --- a/cmd/entire/cli/strategy/push_common.go +++ b/cmd/entire/cli/strategy/push_common.go @@ -11,9 +11,11 @@ import ( "time" "github.com/entireio/cli/cmd/entire/cli/checkpoint" + "github.com/entireio/cli/cmd/entire/cli/gitauth" "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/go-git/go-git/v6" + gitconfig "github.com/go-git/go-git/v6/config" "github.com/go-git/go-git/v6/plumbing" "github.com/go-git/go-git/v6/plumbing/object" ) @@ -145,6 +147,11 @@ func fetchAndMergeSessionsCommon(ctx context.Context, target, branchName string) ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) defer cancel() + repo, err := OpenRepository(ctx) + if err != nil { + return fmt.Errorf("failed to open git repository: %w", err) + } + // Determine fetch refspec. When target is a URL, use a temp ref; // when it's a remote name, use the standard remote-tracking ref. var fetchedRefName plumbing.ReferenceName @@ -158,16 +165,42 @@ func fetchAndMergeSessionsCommon(ctx context.Context, target, branchName string) fetchedRefName = plumbing.NewRemoteReferenceName(target, branchName) } - // Use git CLI for fetch (go-git's fetch can be tricky with auth) - fetchCmd := exec.CommandContext(ctx, "git", "fetch", target, refSpec) - fetchCmd.Stdin = nil - if output, err := fetchCmd.CombinedOutput(); err != nil { - return fmt.Errorf("fetch failed: %s", output) + // Resolve auth and fetch using go-git + var remoteURL string + if isURL(target) { + remoteURL = target + } else { + remoteURL = gitauth.RemoteURL(repo, target) } + auth := gitauth.ResolveAuth(ctx, remoteURL) - repo, err := OpenRepository(ctx) - if err != nil { - return fmt.Errorf("failed to open git repository: %w", err) + if isURL(target) { + // URL-only fetch: create anonymous remote + remote := git.NewRemote(repo.Storer, &gitconfig.RemoteConfig{ + Name: "anonymous", + URLs: []string{target}, + }) + fetchErr := remote.FetchContext(ctx, &git.FetchOptions{ + RemoteName: "anonymous", + RefSpecs: []gitconfig.RefSpec{gitconfig.RefSpec(refSpec)}, + Auth: auth, + Tags: git.NoTags, + Force: true, + }) + if fetchErr != nil && !errors.Is(fetchErr, git.NoErrAlreadyUpToDate) { + return fmt.Errorf("fetch failed: %w", fetchErr) + } + } else { + fetchErr := repo.FetchContext(ctx, &git.FetchOptions{ + RemoteName: target, + RefSpecs: []gitconfig.RefSpec{gitconfig.RefSpec(refSpec)}, + Auth: auth, + Tags: git.NoTags, + Force: true, + }) + if fetchErr != nil && !errors.Is(fetchErr, git.NoErrAlreadyUpToDate) { + return fmt.Errorf("fetch failed: %w", fetchErr) + } } // Reconcile disconnected metadata branches before merging trees. diff --git a/cmd/entire/cli/trail_cmd.go b/cmd/entire/cli/trail_cmd.go index 8795a1ba2..00972b270 100644 --- a/cmd/entire/cli/trail_cmd.go +++ b/cmd/entire/cli/trail_cmd.go @@ -6,13 +6,13 @@ import ( "errors" "fmt" "io" - "os" "os/exec" "slices" "sort" "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/entireio/cli/cmd/entire/cli/stringutil" @@ -20,6 +20,7 @@ import ( "github.com/charmbracelet/huh" "github.com/go-git/go-git/v6" + "github.com/go-git/go-git/v6/config" "github.com/go-git/go-git/v6/plumbing" "github.com/spf13/cobra" ) @@ -561,14 +562,23 @@ func runTrailCreateInteractive(title, body, branch, statusStr *string) error { func fetchTrailsBranch() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - branchName := paths.TrailsBranchName - refSpec := fmt.Sprintf("+refs/heads/%s:refs/remotes/origin/%s", branchName, branchName) - cmd := exec.CommandContext(ctx, "git", "fetch", "origin", refSpec) - // Ensure non-interactive fetch in hook/agent contexts - cmd.Stdin = nil - cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") - _ = cmd.Run() //nolint:errcheck // best-effort fetch + repo, err := openRepository(ctx) + if err != nil { + return + } + + remoteURL := gitauth.RemoteURL(repo, "origin") + auth := gitauth.ResolveAuth(ctx, remoteURL) + + branchName := paths.TrailsBranchName + refSpec := config.RefSpec(fmt.Sprintf("+refs/heads/%s:refs/remotes/origin/%s", branchName, branchName)) + _ = repo.FetchContext(ctx, &git.FetchOptions{ //nolint:errcheck // best-effort fetch + RemoteName: "origin", + RefSpecs: []config.RefSpec{refSpec}, + Auth: auth, + Tags: git.NoTags, + }) } // getTrailAuthor returns the GitHub username for the trail author. From 6b4fbbb0a5e8f2ee9eb1a23079a2f113ea5c720a Mon Sep 17 00:00:00 2001 From: Paulo Gomes Date: Wed, 18 Mar 2026 11:18:27 +0000 Subject: [PATCH 2/2] Fix clean/reset: accurate help text, proper writer injection, logging ordering - Update clean.go checkpoint metadata help text to reflect that orphaned entries may be removed while the branch itself is preserved - Add git repo check before logging.Init in clean.go to prevent creating .entire/logs/ outside a repository - Add logging.Init to reset.go after git repo verification - Refactor Reset/ResetSession to accept io.Writer instead of writing directly to os.Stderr, using cmd.ErrOrStderr() from callers Assisted-by: Claude Opus 4.6 Entire-Checkpoint: cf5ebe6e9619 --- cmd/entire/cli/clean.go | 15 ++++++++----- cmd/entire/cli/reset.go | 14 ++++++++++--- .../cli/strategy/manual_commit_reset.go | 21 +++++++++++-------- 3 files changed, 33 insertions(+), 17 deletions(-) diff --git a/cmd/entire/cli/clean.go b/cmd/entire/cli/clean.go index c8d726530..c98a933b1 100644 --- a/cmd/entire/cli/clean.go +++ b/cmd/entire/cli/clean.go @@ -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) }, @@ -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) diff --git a/cmd/entire/cli/reset.go b/cmd/entire/cli/reset.go index f6217ccf0..c2ffa5e82 100644 --- a/cmd/entire/cli/reset.go +++ b/cmd/entire/cli/reset.go @@ -6,6 +6,7 @@ import ( "fmt" "github.com/charmbracelet/huh" + "github.com/entireio/cli/cmd/entire/cli/logging" "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/entireio/cli/cmd/entire/cli/session" "github.com/entireio/cli/cmd/entire/cli/strategy" @@ -39,11 +40,18 @@ Example: If HEAD is at commit abc1234567890, the command will: Without --force, prompts for confirmation before deleting.`, RunE: func(cmd *cobra.Command, _ []string) error { ctx := cmd.Context() - // Check if in git repository + // Check if in git repository first, before logging init if _, err := paths.WorktreeRoot(ctx); err != nil { return errors.New("not a git repository") } + // Initialize logging after confirming git repo context. + // Error is non-fatal: if logging init fails, logs go to stderr. + logging.SetLogLevelGetter(GetLogLevel) + if err := logging.Init(ctx, ""); err == nil { + defer logging.Close() + } + // Get current strategy strat := GetStrategy(ctx) @@ -94,7 +102,7 @@ Without --force, prompts for confirmation before deleting.`, } // Call strategy's Reset method - if err := strat.Reset(ctx); err != nil { + if err := strat.Reset(ctx, cmd.ErrOrStderr()); err != nil { return fmt.Errorf("reset failed: %w", err) } @@ -146,7 +154,7 @@ func runResetSession(ctx context.Context, cmd *cobra.Command, strat *strategy.Ma } } - if err := strat.ResetSession(ctx, sessionID); err != nil { + if err := strat.ResetSession(ctx, cmd.ErrOrStderr(), sessionID); err != nil { return fmt.Errorf("reset session failed: %w", err) } diff --git a/cmd/entire/cli/strategy/manual_commit_reset.go b/cmd/entire/cli/strategy/manual_commit_reset.go index 21501a3e3..3c5c39f68 100644 --- a/cmd/entire/cli/strategy/manual_commit_reset.go +++ b/cmd/entire/cli/strategy/manual_commit_reset.go @@ -3,6 +3,7 @@ package strategy import ( "context" "fmt" + "io" "os" "github.com/entireio/cli/cmd/entire/cli/paths" @@ -18,7 +19,8 @@ func isAccessibleMode() bool { // Reset deletes the shadow branch and session state for the current HEAD. // This allows starting fresh without existing checkpoints. -func (s *ManualCommitStrategy) Reset(ctx context.Context) error { +// Output is written to w; callers should pass cmd.ErrOrStderr() or similar. +func (s *ManualCommitStrategy) Reset(ctx context.Context, w io.Writer) error { repo, err := OpenRepository(ctx) if err != nil { return fmt.Errorf("failed to open git repository: %w", err) @@ -56,7 +58,7 @@ func (s *ManualCommitStrategy) Reset(ctx context.Context) error { // If nothing to reset, return early if !hasShadowBranch && len(sessions) == 0 { - fmt.Fprintf(os.Stderr, "Nothing to reset for %s\n", shadowBranchName) + fmt.Fprintf(w, "Nothing to reset for %s\n", shadowBranchName) return nil } @@ -64,7 +66,7 @@ func (s *ManualCommitStrategy) Reset(ctx context.Context) error { clearedSessions := make([]string, 0) for _, state := range sessions { if err := s.clearSessionState(ctx, state.SessionID); err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to clear session state for %s: %v\n", state.SessionID, err) + fmt.Fprintf(w, "Warning: failed to clear session state for %s: %v\n", state.SessionID, err) } else { clearedSessions = append(clearedSessions, state.SessionID) } @@ -73,7 +75,7 @@ func (s *ManualCommitStrategy) Reset(ctx context.Context) error { // Report cleared session states with session IDs if len(clearedSessions) > 0 { for _, sessionID := range clearedSessions { - fmt.Fprintf(os.Stderr, "Cleared session state for %s\n", sessionID) + fmt.Fprintf(w, "Cleared session state for %s\n", sessionID) } } @@ -82,7 +84,7 @@ func (s *ManualCommitStrategy) Reset(ctx context.Context) error { if err := DeleteBranchCLI(ctx, shadowBranchName); err != nil { return fmt.Errorf("failed to delete shadow branch: %w", err) } - fmt.Fprintf(os.Stderr, "Deleted shadow branch %s\n", shadowBranchName) + fmt.Fprintf(w, "Deleted shadow branch %s\n", shadowBranchName) } return nil @@ -90,7 +92,8 @@ func (s *ManualCommitStrategy) Reset(ctx context.Context) error { // ResetSession clears a single session's state and removes the shadow branch // if no other sessions reference it. File changes remain in the working directory. -func (s *ManualCommitStrategy) ResetSession(ctx context.Context, sessionID string) error { +// Output is written to w; callers should pass cmd.ErrOrStderr() or similar. +func (s *ManualCommitStrategy) ResetSession(ctx context.Context, w io.Writer, sessionID string) error { // Load the session state state, err := s.loadSessionState(ctx, sessionID) if err != nil { @@ -104,7 +107,7 @@ func (s *ManualCommitStrategy) ResetSession(ctx context.Context, sessionID strin if err := s.clearSessionState(ctx, sessionID); err != nil { return fmt.Errorf("failed to clear session state: %w", err) } - fmt.Fprintf(os.Stderr, "Cleared session state for %s\n", sessionID) + fmt.Fprintf(w, "Cleared session state for %s\n", sessionID) // Determine the shadow branch for this session shadowBranchName := getShadowBranchNameForCommit(state.BaseCommit, state.WorktreeID) @@ -117,12 +120,12 @@ func (s *ManualCommitStrategy) ResetSession(ctx context.Context, sessionID strin // Clean up shadow branch if no other sessions need it if err := s.cleanupShadowBranchIfUnused(ctx, repo, shadowBranchName, sessionID); err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to clean up shadow branch %s: %v\n", shadowBranchName, err) + fmt.Fprintf(w, "Warning: failed to clean up shadow branch %s: %v\n", shadowBranchName, err) } else { // Check if it was actually deleted via git CLI (go-git's cache // may be stale after CLI-based deletion with packed refs) if err := branchExistsCLI(ctx, shadowBranchName); err != nil { - fmt.Fprintf(os.Stderr, "Deleted shadow branch %s\n", shadowBranchName) + fmt.Fprintf(w, "Deleted shadow branch %s\n", shadowBranchName) } }