817 lines
29 KiB
Go
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
|
|
}
|