Skip to content
Merged
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
17 changes: 17 additions & 0 deletions cmd/entire/cli/checkpoint/remote/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,17 @@ func GetRemoteURL(ctx context.Context, remoteName string) (string, error) {
return url, nil
}

// GetPushURLs returns every URL a push to remoteName delivers to, in the order
// git will use them. See gitremote.GetPushURLs for why this differs from
// GetRemoteURL.
func GetPushURLs(ctx context.Context, remoteName string) ([]string, error) {
urls, err := gitremote.GetPushURLs(ctx, remoteName)
if err != nil {
return nil, fmt.Errorf("get push URLs: %w", err)
}
return urls, nil
}

// GetRemoteURLInDir returns the URL configured for the named git remote in dir.
func GetRemoteURLInDir(ctx context.Context, dir, remoteName string) (string, error) {
url, err := gitremote.GetRemoteURLInDir(ctx, dir, remoteName)
Expand Down Expand Up @@ -481,6 +492,12 @@ func RedactURL(rawURL string) string {
return gitremote.RedactURL(rawURL)
}

// RedactURLOrPath is RedactURL for values that may be a remote name or a local
// path rather than a URL. See gitremote.RedactURLOrPath.
func RedactURLOrPath(target string) string {
return gitremote.RedactURLOrPath(target)
}

func logFallback(ctx context.Context, operation, fallbackURL, reason string, err error, attrs ...any) {
logAttrs := []any{
slog.String("operation", operation),
Expand Down
31 changes: 17 additions & 14 deletions cmd/entire/cli/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ func runSessionsFix(cmd *cobra.Command, force bool) error {
// Agent-specific: Claude Code hook config drift.
checkClaudeCodeHookDrift(cmd)

// Where checkpoints land, when the repo's remotes make that ambiguous.
printCheckpointDestinationNote(ctx, cmd.OutOrStdout(), "Checkpoint destination: REVIEW")

// Stuck sessions
// Load all session states
states, err := strategy.ListSessionStates(ctx)
Expand Down Expand Up @@ -432,20 +435,6 @@ func confirmDoctorFix(ctx context.Context, w io.Writer, title string) (bool, err
return confirmed, nil
}

// checkCodexHookTrust warns about two kinds of drift in the Codex hook
// setup:
//
// 1. .codex/hooks.json is stale relative to what the CLI installs
// today (e.g. a release added PostToolUse after the user enabled
// Codex). Fix: re-run `entire enable`.
//
// 2. A declared hook lacks a `trusted_hash` entry in the user's Codex
// config — either a fresh clone or a newer hook on the file the
// user hasn't approved yet. Fix: open /hooks in Codex.
//
// Both checks are structural (file/key presence). Stays silent when
// this repo doesn't have codex hooks installed or when we can't
// resolve the worktree root. Warn-only.
// checkClaudeCodeHookDrift warns when Entire's Claude Code hooks are installed
// but out of date — e.g. an older release wrote tool matchers that no longer
// fire on current Claude Code. Read-only; the fix is `entire enable --force`.
Expand All @@ -464,6 +453,20 @@ func checkClaudeCodeHookDrift(cmd *cobra.Command) {
}
}

// checkCodexHookTrust warns about two kinds of drift in the Codex hook
// setup:
//
// 1. .codex/hooks.json is stale relative to what the CLI installs
// today (e.g. a release added PostToolUse after the user enabled
// Codex). Fix: re-run `entire enable`.
//
// 2. A declared hook lacks a `trusted_hash` entry in the user's Codex
// config — either a fresh clone or a newer hook on the file the
// user hasn't approved yet. Fix: open /hooks in Codex.
//
// Both checks are structural (file/key presence). Stays silent when
// this repo doesn't have codex hooks installed or when we can't
// resolve the worktree root. Warn-only.
func checkCodexHookTrust(cmd *cobra.Command) {
repoRoot, err := paths.WorktreeRoot(cmd.Context())
if err != nil {
Expand Down
44 changes: 44 additions & 0 deletions cmd/entire/cli/gitremote/gitremote.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,34 @@ func GetRemoteURLInDir(ctx context.Context, dir, remoteName string) (string, err
return strings.TrimSpace(string(output)), nil
}

// GetPushURLs returns every URL a push to remoteName delivers to, in the order
// git will use them.
//
// A remote's push destinations are remote.<name>.pushurl when any is set and its
// remote.<name>.url otherwise (git's push_url_of_remote), and BOTH may repeat —
// git pushes to all of them, in config order. So this, not GetRemoteURL,
// describes where a push actually goes; GetRemoteURL reports the FETCH URL,
// which can name a different repository entirely.
//
// Returns at least one entry on success.
func GetPushURLs(ctx context.Context, remoteName string) ([]string, error) {
cmd := exec.CommandContext(ctx, "git", "remote", "get-url", "--push", "--all", remoteName)
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("remote %q not found", remoteName)
}
var urls []string
for _, line := range strings.Split(string(output), "\n") {
if trimmed := strings.TrimSpace(line); trimmed != "" {
urls = append(urls, trimmed)
}
}
if len(urls) == 0 {
return nil, fmt.Errorf("remote %q has no push URL", remoteName)
}
return urls, nil
}

// ParseURL parses a git remote URL (SSH SCP-style or HTTPS) into its components.
func ParseURL(rawURL string) (*Info, error) {
rawURL = strings.TrimSpace(rawURL)
Expand Down Expand Up @@ -182,6 +210,22 @@ func RedactURL(rawURL string) string {
return u.Scheme + "://" + u.Host + u.Path
}

// RedactURLOrPath renders a remote for display with any credentials removed,
// accepting values that are not URLs at all.
//
// RedactURL cannot be applied blanket-fashion: it round-trips through url.Parse
// and rebuilds "scheme://host/path", so a bare filesystem path like
// /srv/repo.git comes back as ":///srv/repo.git" and a bare word like "origin"
// as "://origin". Those inputs carry no credentials, so they pass through
// unchanged. Use this wherever the value may be a remote name, a local path, or
// a URL — i.e. anywhere a push/fetch target is shown to a user.
func RedactURLOrPath(remote string) string {
if strings.Contains(remote, "://") || strings.Contains(remote, "@") {
return RedactURL(remote)
}
return remote
}

// ResolveRemoteRepo returns the forge identifier, owner, and repo name for the
// given git remote. The forge is the short id used by the trails API ("gh",
// "et", ...); it is derived from the hostname for direct git URLs or from the
Expand Down
82 changes: 44 additions & 38 deletions cmd/entire/cli/integration_test/multi_pushurl.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ import (
// every push URL and — this is the part that matters for checkpoint sync —
// invokes the pre-push hook ONCE PER PUSH URL, passing the same remote NAME as
// $1 each time and the individual URL as $2. Our installed hook forwards only
// $1 (see strategy/hooks.go), so the CLI cannot tell the invocations apart and
// hands `git push <name>` the remote name, letting git fan out again.
// $1 (see strategy/hooks.go), so the CLI cannot tell the invocations apart.
// git-branch then hands `git push <name>` the remote name and lets git fan out
// again; git-refs resolves the first push URL itself and targets only that (see
// strategy.resolveRefsPushDestination).
//
// See multi_pushurl_test.go for what that means per checkpoint backend.

Expand Down Expand Up @@ -56,55 +58,38 @@ func (env *TestEnv) AddSecondPushURL(remoteName string) string {
env.T.Fatalf("failed to init second bare repo: %v\n%s", err, output)
}

originalURL := env.RemoteURL(remoteName)

// Re-add the original URL as an explicit push URL first, then the new one,
// so push fans out to both in that order.
for _, url := range []string{originalURL, secondBare} {
cmd = exec.CommandContext(ctx, "git", "remote", "set-url", "--add", "--push", remoteName, url)
cmd.Dir = env.RepoDir
cmd.Env = testutil.GitIsolatedEnv()
if output, err := cmd.CombinedOutput(); err != nil {
env.T.Fatalf("failed to add push URL %s to remote %s: %v\n%s", url, remoteName, err, output)
}
}
// The original URL is re-added explicitly because configuring ANY pushurl
// replaces url for push purposes — adding only the new one would silently
// redirect pushes instead of fanning out.
env.setPushURLs(remoteName, env.RemoteURL(remoteName), secondBare)

// Guard the setup itself: a helper that quietly configured one push URL
// would make every fan-out assertion below vacuous.
// would make every fan-out assertion vacuous.
if got := env.PushURLs(remoteName); len(got) != 2 {
env.T.Fatalf("expected 2 push URLs on remote %s, got %d: %v", remoteName, len(got), got)
}

// Re-baseline the .git/config guard: adding push URLs is a deliberate
// change, so it must not read as unexpected drift at cleanup.
env.setGitConfigBaseline()

return secondBare
}

// AddUnreachableSecondPushURL configures remoteName to fan out to its original
// URL plus a path that does not exist, so every push to that remote partially
// fails: the real URL receives the refs, the bogus one errors, and git exits
// non-zero. Models a mirror that is down or whose credentials have expired.
// Returns the unreachable path.
// AddUnreachableSecondPushURL configures remoteName to push to its original URL
// first and a path that does not exist second. Models a mirror that is down or
// whose credentials have expired, in the position where git still reaches the
// healthy URL before failing. Returns the unreachable path.
func (env *TestEnv) AddUnreachableSecondPushURL(remoteName string) string {
env.T.Helper()

ctx := env.T.Context()
missing := filepath.Join(env.T.TempDir(), "does-not-exist.git")
originalURL := env.RemoteURL(remoteName)

for _, url := range []string{originalURL, missing} {
cmd := exec.CommandContext(ctx, "git", "remote", "set-url", "--add", "--push", remoteName, url)
cmd.Dir = env.RepoDir
cmd.Env = testutil.GitIsolatedEnv()
if output, err := cmd.CombinedOutput(); err != nil {
env.T.Fatalf("failed to add push URL %s to remote %s: %v\n%s", url, remoteName, err, output)
}
}

env.setGitConfigBaseline()
env.setPushURLs(remoteName, env.RemoteURL(remoteName), missing)
return missing
}

// AddUnreachableFirstPushURL is AddUnreachableSecondPushURL with the unreachable
// path FIRST — the position that matters, because a transport failure makes git
// die() rather than return, so no later URL is attempted at all.
func (env *TestEnv) AddUnreachableFirstPushURL(remoteName string) string {
env.T.Helper()
missing := filepath.Join(env.T.TempDir(), "does-not-exist.git")
env.setPushURLs(remoteName, missing, env.RemoteURL(remoteName))
return missing
}

Expand All @@ -126,6 +111,27 @@ func (env *TestEnv) GitPushWithHooksAllowError(remote, refSpec string) error {
return err //nolint:wrapcheck // test helper: the caller asserts on presence/absence, not identity
}

// setPushURLs appends push URLs to remoteName in the given order and
// re-baselines the .git/config guard (changing push URLs is deliberate here).
//
// Order is the parameter that matters: git iterates push URLs in config order,
// and a transport failure is fatal, so a broken URL first behaves differently
// from the same URL last.
func (env *TestEnv) setPushURLs(remoteName string, urls ...string) {
env.T.Helper()

for _, url := range urls {
cmd := exec.CommandContext(env.T.Context(), "git", "remote", "set-url", "--add", "--push", remoteName, url)
cmd.Dir = env.RepoDir
cmd.Env = testutil.GitIsolatedEnv()
if output, err := cmd.CombinedOutput(); err != nil {
env.T.Fatalf("failed to add push URL %s to remote %s: %v\n%s", url, remoteName, err, output)
}
}

env.setGitConfigBaseline()
}

// RemoteURL returns the fetch URL configured for remoteName.
func (env *TestEnv) RemoteURL(remoteName string) string {
env.T.Helper()
Expand Down
Loading
Loading