Files
kandev/apps/backend/internal/worktree/manager_git.go
T
2026-09-22 23:36:07 +01:00

817 lines
29 KiB
Go

package worktree
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/common/subproc"
)
// pullRequestSnapshotRef is internal state owned by Kandev. Keeping PR heads
// outside refs/remotes/origin prevents ordinary remote pruning or a user branch
// named pr/<N> from changing the commit selected for a task launch.
func pullRequestSnapshotRef(prNumber int) string {
return fmt.Sprintf("refs/kandev/pull/%d/head", prNumber)
}
// isGitRepo checks if a path is a Git repository.
func (m *Manager) isGitRepo(path string) bool {
gitDir := filepath.Join(path, ".git")
info, err := os.Stat(gitDir)
if err != nil {
return false
}
// .git can be either a directory (regular repo) or a file (worktree)
return info.IsDir() || info.Mode().IsRegular()
}
// branchExists checks if a branch exists in the repository.
// Bounded by m.inspectTimeout so a hung git (credential prompt, stuck filter,
// filesystem stall) cannot deadlock the caller while holding repoLock.
//
// Returns:
// - (true, nil) branch exists
// - (false, nil) git ran and reported the branch absent
// - (false, err) check could not be completed (timeout, fs stall); err
// carries the underlying ctx error so callers can distinguish a real
// "missing branch" from a "could not tell" and avoid surfacing a
// misleading ErrInvalidBaseBranch.
func (m *Manager) branchExists(ctx context.Context, repoPath, branch string) (bool, error) {
runErr, execCtxErr := subproc.RunGitAfterAcquire(
ctx,
subproc.GitLifecycle,
m.inspectTimeout,
func(execCtx context.Context) *exec.Cmd {
return m.newNonInteractiveGitCmd(execCtx, repoPath, "rev-parse", "--verify", branch)
},
)
if runErr != nil {
if ctxErr := firstContextError(execCtxErr, runErr); ctxErr != nil {
m.logger.Warn("branchExists bounded by context",
zap.String("repository_path", repoPath),
zap.String("branch", branch),
zap.Error(ctxErr))
return false, fmt.Errorf("branch check timed out for %q after %s: %w", branch, m.inspectTimeout, ctxErr)
}
return false, nil
}
return true, nil
}
// remoteBranchExists performs an authoritative, bounded probe for a branch on
// origin. A successful `ls-remote --exit-code` with exit status 2 means the
// remote answered and did not advertise the requested ref. Any other failure
// remains an error because transport, authentication, and timeout failures do
// not prove that the branch was deleted.
func (m *Manager) remoteBranchExists(ctx context.Context, repoPath, branch string) (bool, error) {
branch = normalizeOriginBranchName(branch)
if branch == "" {
return false, fmt.Errorf("remote branch name is empty: %w", ErrGitCommandFailed)
}
output, err := m.runBoundedGitInspect(
ctx,
repoPath,
"ls-remote",
"--exit-code",
"--heads",
"origin",
"refs/heads/"+branch,
)
if err == nil {
return true, nil
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) && exitErr.ExitCode() == 2 {
return false, nil
}
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return false, err
}
if containsAuthFailure(strings.ToLower(output)) {
return false, ErrAuthFailed
}
return false, ErrGitCommandFailed
}
func normalizeOriginBranchName(branch string) string {
branch = strings.TrimSpace(branch)
for _, prefix := range []string{"refs/remotes/origin/", "refs/heads/", "origin/"} {
branch = strings.TrimPrefix(branch, prefix)
}
return branch
}
// runBoundedGitInspect runs a non-interactive local git inspection after
// acquiring the lifecycle throttle. The timeout starts after admission so
// queue wait does not consume the command's inspection budget.
func (m *Manager) runBoundedGitInspect(ctx context.Context, repoPath string, args ...string) (string, error) {
output, runErr, execCtxErr := subproc.RunGitCombinedAfterAcquire(
ctx,
subproc.GitLifecycle,
m.inspectTimeout,
func(execCtx context.Context) *exec.Cmd {
return m.newNonInteractiveGitCmd(execCtx, repoPath, args...)
},
)
if ctxErr := firstContextError(execCtxErr, runErr); ctxErr != nil {
return string(output), fmt.Errorf("git inspection timed out: %w", ctxErr)
}
return string(output), runErr
}
// checkoutBranchExistsAnywhere returns true when the named branch is present
// either locally or as origin/<branch>. Used by createInTaskDir to decide
// whether to treat req.CheckoutBranch as "fetch this existing ref" or as
// "create a new branch with this name". A timeout / fs stall counts as
// "present" so we don't accidentally clobber a working branch by creating a
// duplicate when the probe couldn't complete.
func (m *Manager) checkoutBranchExistsAnywhere(ctx context.Context, repoPath, branch string) bool {
local, err := m.branchExists(ctx, repoPath, branch)
if err != nil {
return true
}
if local {
return true
}
remote, err := m.branchExists(ctx, repoPath, "refs/remotes/origin/"+branch)
if err != nil {
return true
}
return remote
}
func (m *Manager) preferRefreshedRemoteRef(ctx context.Context, repoPath, branch string) (string, error) {
localBranch := strings.TrimPrefix(branch, "origin/")
if localBranch == "" {
return branch, nil
}
remoteRef := "origin/" + localBranch
return m.selectContainingRef(ctx, repoPath, branch, remoteRef)
}
func (m *Manager) resolveRefreshedBaseRefWithFallback(
ctx context.Context, repoPath, baseBranch, fallbackBaseBranch string,
) (string, string, string, string, error) {
resolved, err := m.preferRefreshedRemoteRef(ctx, repoPath, baseBranch)
if err == nil {
return resolved, "", "", "", nil
}
if ctxErr := ctx.Err(); ctxErr != nil {
return "", "", "", "", ctxErr
}
reason := classifyBaseRefSelectionFailure(err)
fallback := strings.TrimSpace(fallbackBaseBranch)
if reason == gitFallbackReasonMissingRemoteRef && fallback != "" && fallback != baseBranch {
resolvedFallback, _, _, _, fallbackErr := m.resolveRefreshedBaseRefWithFallback(ctx, repoPath, fallback, "")
if fallbackErr == nil {
return resolvedFallback, "", "", fallback, nil
}
return "", "", "", "", fallbackErr
}
if isRemoteOnlyBaseRef(baseBranch) {
return "", "", "", "", err
}
localExists, localErr := m.branchExists(ctx, repoPath, baseBranch)
if localErr != nil {
return "", "", "", "", fmt.Errorf("could not verify local base ref %q: %w", baseBranch, localErr)
}
if !localExists {
return "", "", "", "", err
}
warning, detail := localBaseRefreshWarning(reason, baseBranch)
m.logger.Warn("refreshed remote base was incomplete; using local base",
zap.String("branch", baseBranch),
zap.String("reason", reason),
zap.String("fallback_ref", baseBranch),
zap.Error(err),
)
return baseBranch, warning, detail, "", nil
}
// prepareCheckoutFromRefreshedOrigin verifies a refreshed remote branch and
// returns whether a usable local or remote ref exists without contacting
// origin. It returns false when neither ref exists, preserving the existing
// "create a new branch from base" behavior.
func (m *Manager) prepareCheckoutFromRefreshedOrigin(ctx context.Context, repoPath, branch string) (bool, error) {
selected, err := m.prepareBranchFromRefreshedOrigin(ctx, repoPath, branch, branch, 0)
return selected != "", err
}
// prepareBranchFromRefreshedOrigin selects a provider-refreshed source branch
// without contacting origin. A PR number selects the dedicated Kandev-owned
// snapshot ref, which is also how fork PR heads are kept available after the
// authenticated refresh. PR preparation never creates or resets a local
// branch with the PR's source name.
func (m *Manager) prepareBranchFromRefreshedOrigin(
ctx context.Context, repoPath, localBranch, sourceBranch string, prNumber int,
) (string, error) {
remoteRef := "origin/" + sourceBranch
if prNumber > 0 {
remoteRef = pullRequestSnapshotRef(prNumber)
}
localExists, err := m.branchExists(ctx, repoPath, localBranch)
if err != nil {
return "", err
}
remoteExists, err := m.branchExists(ctx, repoPath, remoteRef)
if err != nil {
return "", err
}
if prNumber > 0 {
if !remoteExists {
return "", fmt.Errorf("required fetched remote ref %q is missing: %w", remoteRef, ErrWorkspaceCheckoutFailed)
}
return remoteRef, nil
}
if !localExists && !remoteExists {
return "", nil
}
if !remoteExists {
return "", fmt.Errorf("required fetched remote ref %q is missing", remoteRef)
}
if !localExists {
output, runErr, execCtxErr := subproc.RunGitCombinedAfterAcquire(
ctx,
subproc.GitLifecycle,
m.inspectTimeout,
func(execCtx context.Context) *exec.Cmd {
return m.newNonInteractiveGitCmd(execCtx, repoPath, "branch", "--track", localBranch, remoteRef)
},
)
if runErr != nil {
if ctxErr := firstContextError(execCtxErr, runErr); ctxErr != nil {
return "", ctxErr
}
return "", fmt.Errorf("create local branch %q from refreshed origin: %s: %w",
localBranch, strings.TrimSpace(string(output)), runErr)
}
return localBranch, nil
}
return m.selectContainingRef(ctx, repoPath, localBranch, remoteRef)
}
// Branch recovery statuses reported by BranchRecoveryStatus.
const (
BranchStatusLocal = "local"
BranchStatusRemote = "remote"
BranchStatusMissing = "missing"
)
// BranchRecoveryStatus reports where a worktree branch still exists:
// "local" when refs/heads/<branch> resolves, "remote" when only the
// remote-tracking ref refs/remotes/origin/<branch> does, "missing"
// otherwise. Offline-friendly best-effort probe — it inspects local refs
// only (no network), so a remote-tracking ref that was deleted upstream
// but not yet pruned still reports "remote"; the recreate-time fetch is
// the authoritative check.
func (m *Manager) BranchRecoveryStatus(ctx context.Context, repoPath, branch string) string {
if repoPath == "" || branch == "" {
return BranchStatusMissing
}
if exists, err := m.branchExists(ctx, repoPath, "refs/heads/"+branch); err == nil && exists {
return BranchStatusLocal
}
if exists, err := m.branchExists(ctx, repoPath, "refs/remotes/origin/"+branch); err == nil && exists {
return BranchStatusRemote
}
return BranchStatusMissing
}
// RecoverBranchStatus restores a safely compacted managed branch from its
// persisted exact head before reporting status. It performs no fetch.
func (m *Manager) RecoverBranchStatus(ctx context.Context, wt *Worktree) string {
if wt == nil || wt.RepositoryPath == "" || wt.Branch == "" {
return BranchStatusMissing
}
repoLock := m.getRepoLock(wt.RepositoryPath)
repoLock.Lock()
defer func() {
repoLock.Unlock()
m.releaseRepoLock(wt.RepositoryPath)
}()
branchRef := "refs/heads/" + wt.Branch
if exists, err := m.branchExists(ctx, wt.RepositoryPath, branchRef); err == nil && exists {
return m.existingRecoveredBranchStatus(ctx, wt, branchRef)
}
if m.restoreManagedBranchFromRecoveryHeadLocked(ctx, wt) == nil && wt.RecoveryHeadSHA != "" {
return BranchStatusLocal
}
return m.BranchRecoveryStatus(ctx, wt.RepositoryPath, wt.Branch)
}
func (m *Manager) existingRecoveredBranchStatus(ctx context.Context, wt *Worktree, branchRef string) string {
if wt.RecoveryHeadSHA == "" {
return BranchStatusLocal
}
current, err := m.resolveCommit(ctx, wt.RepositoryPath, branchRef)
if err != nil || !strings.EqualFold(current, wt.RecoveryHeadSHA) {
return BranchStatusMissing
}
if m.finalizeRestoredManagedBranch(ctx, wt, current) != nil {
return BranchStatusMissing
}
return BranchStatusLocal
}
func (m *Manager) restoreManagedBranchFromRecoveryHeadLocked(ctx context.Context, wt *Worktree) error {
if wt == nil || wt.BranchOwner != BranchOwnerManaged || wt.RecoveryHeadSHA == "" {
return fmt.Errorf("managed branch recovery metadata is unavailable")
}
inspectCtx, cancel := context.WithTimeout(ctx, m.inspectTimeout)
defer cancel()
resolved, err := m.resolveCommit(inspectCtx, wt.RepositoryPath, wt.RecoveryHeadSHA)
if err != nil || resolved != strings.ToLower(wt.RecoveryHeadSHA) {
return fmt.Errorf("recovery commit is unavailable")
}
branchRef := "refs/heads/" + wt.Branch
zeroOID := strings.Repeat("0", len(resolved))
cmd := m.newNonInteractiveGitCmd(inspectCtx, wt.RepositoryPath, "update-ref", branchRef, resolved, zeroOID)
if output, err := runGitCmdCombinedOutput(inspectCtx, cmd); err != nil {
return fmt.Errorf("restore managed branch: %s: %w", strings.TrimSpace(string(output)), err)
}
if err := m.finalizeRestoredManagedBranch(ctx, wt, resolved); err != nil {
return err
}
return nil
}
func (m *Manager) finalizeRestoredManagedBranch(ctx context.Context, wt *Worktree, resolved string) error {
if wt.RecoveryHeadSHA == "" || !strings.EqualFold(wt.RecoveryHeadSHA, resolved) {
return fmt.Errorf("restored managed branch does not match its recovery head")
}
metadataStore, ok := m.store.(BranchMetadataStore)
if !ok {
return fmt.Errorf("persist restored compacted branch: metadata store is unavailable")
}
if !m.deleteRecoveryRef(ctx, wt, resolved) {
return fmt.Errorf("remove managed branch recovery ref")
}
persisted, persistErr := metadataStore.PersistBranchRecoveryRestored(ctx, wt.ID, resolved)
if persistErr != nil || !persisted {
return fmt.Errorf("persist restored compacted branch: %w", persistErr)
}
wt.RecoveryHeadSHA = ""
wt.BranchCompactedAt = nil
return nil
}
// refContains reports whether container already includes every commit in
// contained, i.e. `git merge-base --is-ancestor contained container`.
// A non-zero ancestry result is distinct from a failed probe: the former is a
// proven negative, while the latter must stop required refresh preparation.
func (m *Manager) refContains(ctx context.Context, repoPath, container, contained string) (bool, error) {
runErr, execCtxErr := subproc.RunGitAfterAcquire(
ctx,
subproc.GitLifecycle,
m.inspectTimeout,
func(execCtx context.Context) *exec.Cmd {
return m.newNonInteractiveGitCmd(execCtx, repoPath, "merge-base", "--is-ancestor", contained, container)
},
)
if ctxErr := firstContextError(execCtxErr, runErr); ctxErr != nil {
return false, fmt.Errorf("git ancestry check timed out: %w", ctxErr)
}
if runErr == nil {
return true, nil
}
var exitErr *exec.ExitError
if errors.As(runErr, &exitErr) && exitErr.ExitCode() == 1 {
return false, nil
}
return false, fmt.Errorf("git ancestry check failed for %q and %q: %w", contained, container, runErr)
}
func (m *Manager) currentBranch(ctx context.Context, repoPath string) string {
output, runErr, execCtxErr := subproc.RunGitOutputAfterAcquire(
ctx,
subproc.GitLifecycle,
m.inspectTimeout,
func(execCtx context.Context) *exec.Cmd {
return m.newNonInteractiveGitCmd(execCtx, repoPath, "rev-parse", "--abbrev-ref", "HEAD")
},
)
if runErr != nil {
if ctxErr := firstContextError(execCtxErr, runErr); ctxErr != nil {
m.logger.Warn("currentBranch bounded by context",
zap.String("repository_path", repoPath),
zap.Error(ctxErr))
}
return ""
}
return strings.TrimSpace(string(output))
}
func (m *Manager) newNonInteractiveGitCmd(ctx context.Context, repoPath string, args ...string) *exec.Cmd {
cmd := newGitCommand(ctx, args...)
cmd.Dir = repoPath
env := cmd.Env
if env == nil {
env = os.Environ()
}
cmd.Env = subproc.PrepareGitEnvironment(env)
// After the context cancels and the process is killed, child processes
// (e.g. credential helpers) may still hold stdout/stderr pipes open.
// WaitDelay bounds how long CombinedOutput waits for those pipes to close.
cmd.WaitDelay = 500 * time.Millisecond
return cmd
}
const gitFallbackReasonMissingRemoteRef = "missing_remote_ref"
func classifyGitFallbackReason(cmdErr error, cmdOutput string, ctxErr error) string {
if errors.Is(ctxErr, context.DeadlineExceeded) || errors.Is(cmdErr, context.DeadlineExceeded) {
return "timeout"
}
if containsAuthFailure(strings.ToLower(cmdOutput)) {
return "non_interactive_auth_failed"
}
if isRemoteBranchMissingError(cmdOutput) {
return gitFallbackReasonMissingRemoteRef
}
return "git_command_failed"
}
// pullBaseBranch attempts to refresh origin and returns the safest ref to use
// for creating a new worktree. The function handles three scenarios:
//
// 1. baseBranch is already a remote ref (e.g., "origin/main"): fetch and use it directly
// 2. baseBranch is a local branch and we're currently on it: pull --ff-only to update
// 3. baseBranch is a local branch but we're on a different branch: use origin/<branch> instead
//
// A local base makes refresh best effort. Remote-only refs still require a
// successful fetch and an independently verified remote ref.
func (m *Manager) pullBaseBranch(
ctx context.Context, repoPath, baseBranch string, onProgress SyncProgressCallback,
) (string, error) {
ref, _, err := m.pullBaseBranchWithPolicy(ctx, repoPath, baseBranch, "", false, onProgress)
return ref, err
}
func (m *Manager) pullBaseBranchWithFallback(
ctx context.Context, repoPath, baseBranch, fallbackBaseBranch string, onProgress SyncProgressCallback,
) (string, string, error) {
return m.pullBaseBranchWithPolicy(ctx, repoPath, baseBranch, fallbackBaseBranch, true, onProgress)
}
func (m *Manager) pullBaseBranchWithPolicy(
ctx context.Context, repoPath, baseBranch, fallbackBaseBranch string, required bool,
onProgress SyncProgressCallback,
) (string, string, error) {
localBranch := strings.TrimPrefix(baseBranch, "origin/")
isRemoteRef := localBranch != baseBranch
stepName := "Sync base branch"
localBaseExists := false
m.reportSyncProgress(onProgress, SyncProgressEvent{
StepName: stepName,
Status: SyncProgressRunning,
Output: fmt.Sprintf("Fetching latest changes for %s", baseBranch),
})
if !isRemoteRef {
var err error
localBaseExists, err = m.branchExists(ctx, repoPath, baseBranch)
if err != nil {
return "", "", m.failBaseBranchProbe(ctx, stepName, baseBranch, onProgress, err)
}
}
// Acquire the git throttle slot first, then start the fetch timer.
// Order matters: the previous "build fetchCtx, then runGitCmd" shape
// let throttle queue time burn the fetch budget while we waited for a
// slot, and the cmd was killed with `signal: killed` the moment it
// got one (70s-lock-held trace under contention).
fetchArgs := []string{"fetch", gitNoTags, "origin"}
if localBranch != "" {
fetchArgs = append(fetchArgs, localBranch)
}
output, err, execCtxErr := m.runGitCombinedAfterAcquire(ctx, m.fetchTimeout, repoPath, fetchArgs...)
if err != nil {
return m.handleBaseFetchFailure(
ctx, repoPath, stepName, baseBranch, fallbackBaseBranch, required,
localBaseExists, output, err, execCtxErr, onProgress,
)
}
if isRemoteRef {
return m.resolveFetchedRemoteBase(ctx, repoPath, stepName, baseBranch, localBranch, required, onProgress)
}
resolved, resolveErr := m.resolveLocalBaseRef(ctx, repoPath, baseBranch, localBranch, stepName, onProgress, localBaseExists && !required)
return resolved, "", resolveErr
}
func (m *Manager) failBaseBranchProbe(
ctx context.Context, stepName, baseBranch string, onProgress SyncProgressCallback, err error,
) error {
reason := "base_ref_unverified"
if ctxErr := ctx.Err(); ctxErr != nil {
reason = syncContextFailureReason(ctxErr)
err = ctxErr
}
return m.failRequiredSync(stepName, baseBranch, onProgress, reason, err)
}
func (m *Manager) handleBaseFetchFailure(
ctx context.Context,
repoPath, stepName, baseBranch, fallbackBaseBranch string,
required, localBaseExists bool,
output []byte,
err, execCtxErr error,
onProgress SyncProgressCallback,
) (string, string, error) {
reason := classifyGitFallbackReason(err, string(output), execCtxErr)
m.logRefreshDiagnostic("fetch", repoPath, baseBranch, reason, output, err, execCtxErr)
if required {
fallback := strings.TrimSpace(fallbackBaseBranch)
if reason == gitFallbackReasonMissingRemoteRef && fallback != "" && fallback != baseBranch {
resolved, _, fallbackErr := m.pullBaseBranchWithPolicy(ctx, repoPath, fallback, "", true, onProgress)
if fallbackErr == nil {
return resolved, fallback, nil
}
return "", "", fallbackErr
}
failureCause := syncFailureCause(reason, err, execCtxErr)
if ctxErr := ctx.Err(); ctxErr != nil {
reason = syncContextFailureReason(ctxErr)
failureCause = ctxErr
}
return "", "", m.failRequiredSync(
stepName, baseBranch, onProgress, reason,
fmt.Errorf("required refresh of %q failed (%s): %w", baseBranch, reason, failureCause),
)
}
if localBaseExists {
resolved, warningErr := m.completeSyncWithWarning(
ctx, stepName, baseBranch, "Fetch", reason, baseBranch, onProgress,
syncFailureCause(reason, err, execCtxErr),
)
return resolved, "", warningErr
}
failureCause := syncFailureCause(reason, err, execCtxErr)
if ctxErr := ctx.Err(); ctxErr != nil {
reason = syncContextFailureReason(ctxErr)
failureCause = ctxErr
}
return "", "", m.failRequiredSync(
stepName, baseBranch, onProgress, reason,
fmt.Errorf("required refresh of %q failed (%s): %w", baseBranch, reason, failureCause),
)
}
func (m *Manager) resolveFetchedRemoteBase(
ctx context.Context,
repoPath, stepName, baseBranch, localBranch string,
required bool,
onProgress SyncProgressCallback,
) (string, string, error) {
resolved := "origin/" + localBranch
if exists, branchErr := m.branchExists(ctx, repoPath, resolved); branchErr != nil {
if required {
return "", "", m.failRequiredSync(stepName, baseBranch, onProgress, "base_ref_unverified", branchErr)
}
return "", "", branchErr
} else if !exists {
return "", "", m.failRequiredSync(
stepName, baseBranch, onProgress, gitFallbackReasonMissingRemoteRef,
fmt.Errorf("required fetched remote ref %q is missing", resolved),
)
}
m.reportSyncCompleted(stepName, onProgress, fmt.Sprintf("Synced and using %s", resolved), "")
return resolved, "", nil
}
func (m *Manager) reportSyncProgress(cb SyncProgressCallback, event SyncProgressEvent) {
if cb != nil {
cb(event)
}
}
func (m *Manager) reportSyncCompleted(stepName string, onProgress SyncProgressCallback, output, errOutput string) {
m.reportSyncProgress(onProgress, SyncProgressEvent{
StepName: stepName,
Status: SyncProgressCompleted,
Output: output,
Error: strings.TrimSpace(errOutput),
})
}
func (m *Manager) completeSyncWithWarning(
ctx context.Context,
stepName, baseBranch, operation, reason, selectedRef string,
onProgress SyncProgressCallback, cause error,
) (string, error) {
if ctxErr := ctx.Err(); ctxErr != nil {
return "", m.failRequiredSync(stepName, baseBranch, onProgress, syncContextFailureReason(ctxErr), ctxErr)
}
m.logger.Warn("git refresh was incomplete; using selected base",
zap.String("branch", baseBranch),
zap.String("reason", reason),
zap.String("fallback_ref", selectedRef),
zap.Error(cause))
warning, detail := localBaseRefreshWarning(reason, selectedRef)
m.reportSyncProgress(onProgress, SyncProgressEvent{
StepName: stepName,
Status: SyncProgressCompleted,
Output: fmt.Sprintf("%s %s; using %s", operation, reason, selectedRef),
Warning: warning,
WarningDetail: detail,
})
return selectedRef, nil
}
func localBaseRefreshWarning(reason, selectedRef string) (string, string) {
baseKind := "local base"
if isRemoteOnlyBaseRef(selectedRef) {
baseKind = "remote-tracking base"
}
return fmt.Sprintf(
"Remote refresh was incomplete (%s); using %s %q. Remote changes may be missing.",
reason, baseKind, selectedRef,
), fmt.Sprintf("The selected %s was verified before refresh. Kandev did not change Git refs.", baseKind)
}
func localCheckoutBranchRefreshDetail(branch string) string {
return fmt.Sprintf("The local checkout branch %q was verified after refresh failed. Kandev did not change Git refs.", branch)
}
func syncContextFailureReason(err error) string {
if errors.Is(err, context.DeadlineExceeded) {
return "timeout"
}
if errors.Is(err, context.Canceled) {
return "canceled"
}
return "git_command_failed"
}
func (m *Manager) failRequiredSync(
stepName, branch string, onProgress SyncProgressCallback, reason string, err error,
) error {
m.logger.Warn("required git refresh failed before worktree creation",
zap.String("branch", branch),
zap.String("reason", reason),
zap.Error(err))
m.reportSyncProgress(onProgress, SyncProgressEvent{
StepName: stepName,
Status: SyncProgressFailed,
Output: fmt.Sprintf("Required refresh failed for %s (%s)", branch, reason),
Error: reason,
})
return err
}
func (m *Manager) resolveLocalBaseRef(
ctx context.Context, repoPath, baseBranch, localBranch, stepName string,
onProgress SyncProgressCallback, localBaseExists bool,
) (string, error) {
remoteRef := "origin/" + localBranch
if m.currentBranch(ctx, repoPath) == baseBranch {
return m.pullCurrentBranchOrFallback(ctx, repoPath, baseBranch, remoteRef, stepName, onProgress, localBaseExists)
}
resolved, err := m.selectContainingRef(ctx, repoPath, baseBranch, remoteRef)
if err != nil {
if localBaseExists {
return m.completeSyncWithWarning(
ctx, stepName, baseBranch, "Base ref selection", classifyBaseRefSelectionFailure(err),
baseBranch, onProgress, err,
)
}
return "", m.failRequiredSync(stepName, baseBranch, onProgress, "base_ref_unverified", err)
}
m.reportSyncCompleted(stepName, onProgress, fmt.Sprintf("Synced and using %s", resolved), "")
return resolved, nil
}
func (m *Manager) pullCurrentBranchOrFallback(
ctx context.Context, repoPath, baseBranch, remoteRef, stepName string,
onProgress SyncProgressCallback, localBaseExists bool,
) (string, error) {
// Same Acquire-then-build-execCtx ordering as the fetch path.
output, err, execCtxErr := m.runGitCombinedAfterAcquire(ctx, m.pullTimeout, repoPath, "pull", "--ff-only", "origin", baseBranch)
if err != nil {
reason := classifyGitFallbackReason(err, string(output), execCtxErr)
m.logRefreshDiagnostic("pull", repoPath, baseBranch, reason, output, err, execCtxErr)
resolved, selectErr := m.selectContainingRef(ctx, repoPath, baseBranch, remoteRef)
if selectErr != nil {
if !localBaseExists {
return "", m.failRequiredSync(stepName, baseBranch, onProgress, reason, selectErr)
}
return m.completeSyncWithWarning(
ctx, stepName, baseBranch, "Pull", classifyBaseRefSelectionFailure(selectErr),
baseBranch, onProgress, selectErr,
)
}
if localBaseExists {
return m.completeSyncWithWarning(ctx, stepName, baseBranch, "Pull", reason, resolved, onProgress, err)
}
return "", m.failRequiredSync(stepName, baseBranch, onProgress, reason, err)
}
m.reportSyncCompleted(stepName, onProgress, fmt.Sprintf("Synced and using %s", baseBranch), "")
return baseBranch, nil
}
func classifyBaseRefSelectionFailure(err error) string {
lowerErr := strings.ToLower(err.Error())
switch {
case strings.Contains(lowerErr, "diverged"):
return "diverged_refs"
case strings.Contains(lowerErr, "missing"):
return "missing_remote_ref"
default:
return "base_ref_unverified"
}
}
func (m *Manager) selectContainingRef(
ctx context.Context, repoPath, localRef, remoteRef string,
) (string, error) {
remoteExists, err := m.branchExists(ctx, repoPath, remoteRef)
if err != nil {
return "", fmt.Errorf("could not verify fetched remote ref %q: %w", remoteRef, err)
}
if !remoteExists {
return "", fmt.Errorf("required fetched remote ref %q is missing", remoteRef)
}
if localRef == remoteRef {
return remoteRef, nil
}
localExists, err := m.branchExists(ctx, repoPath, localRef)
if err != nil {
return "", fmt.Errorf("could not verify local base ref %q: %w", localRef, err)
}
if !localExists {
return remoteRef, nil
}
remoteContainsLocal, err := m.refContains(ctx, repoPath, remoteRef, localRef)
if err != nil {
return "", err
}
if remoteContainsLocal {
return remoteRef, nil
}
localContainsRemote, err := m.refContains(ctx, repoPath, localRef, remoteRef)
if err != nil {
return "", err
}
if localContainsRemote {
return localRef, nil
}
return "", fmt.Errorf("base refs %q and %q diverged", localRef, remoteRef)
}
// syncFailureCause intentionally suppresses cmdErr because Git output can
// contain credentials. Callers expose only a bounded failure class and never
// include the command output in errors or progress.
func syncFailureCause(reason string, _ error, contextErr error) error {
if contextErr != nil {
return contextErr
}
switch reason {
case "timeout":
return context.DeadlineExceeded
case "non_interactive_auth_failed":
return ErrAuthFailed
default:
return ErrGitCommandFailed
}
}
// runGitCombinedAfterAcquire acquires the backend git throttle slot,
// then constructs a child context with execTimeout and runs the
// non-interactive git command with CombinedOutput. The exec timer
// starts only AFTER Acquire returns so throttle queue time cannot
// burn the budget (otherwise the cmd gets killed with `signal: killed`
// the moment it acquires a slot under contention).
// Returns (combined output, run error, exec-ctx error). The exec-ctx
// error lets callers tell a context-driven kill (timeout) from a
// regular git failure when classifying fallbacks.
func (m *Manager) runGitCombinedAfterAcquire(
ctx context.Context, execTimeout time.Duration, repoPath string, args ...string,
) ([]byte, error, error) {
return subproc.RunGitCombinedAfterAcquire(ctx, subproc.GitLifecycle, execTimeout, func(execCtx context.Context) *exec.Cmd {
return m.newNonInteractiveGitCmd(execCtx, repoPath, args...)
})
}
func firstContextError(execCtxErr, runErr error) error {
if execCtxErr != nil {
return execCtxErr
}
if errors.Is(runErr, context.Canceled) || errors.Is(runErr, context.DeadlineExceeded) {
return runErr
}
return nil
}