216 lines
7.1 KiB
Go
216 lines
7.1 KiB
Go
package worktree
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
const replacementBranchAttempts = 20
|
|
|
|
// replaceUnrecoverableWorktree creates a fresh branch and worktree while
|
|
// retaining the existing task-environment repository record. The caller holds
|
|
// the repository lock from recreate, so this helper must not acquire it again.
|
|
func (m *Manager) replaceUnrecoverableWorktree(
|
|
ctx context.Context,
|
|
existing *Worktree,
|
|
req CreateRequest,
|
|
branchErr *BranchUnrecoverableError,
|
|
) (*Worktree, error) {
|
|
if existing == nil {
|
|
return nil, branchErr
|
|
}
|
|
replacementReq, err := replacementRequest(existing, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
baseRef, fallbackWarning, fallbackDetail, err := m.resolveBaseRefWithFallback(ctx, &replacementReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve replacement base branch: %w", err)
|
|
}
|
|
branchName, worktreePath, err := m.replacementTarget(ctx, replacementReq)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := m.gitAddWorktree(ctx, replacementReq.RepositoryPath, branchName, worktreePath, baseRef); err != nil {
|
|
return nil, fmt.Errorf("create replacement worktree for %q: %w", branchErr.BranchName(), err)
|
|
}
|
|
|
|
replacement := replacementRecord(existing, replacementReq, branchName, worktreePath, fallbackWarning, fallbackDetail)
|
|
if err := m.persistReplacementWorktree(ctx, &replacement); err != nil {
|
|
if cleanupErr := m.cleanupFailedReplacement(ctx, &replacement); cleanupErr != nil {
|
|
return nil, errors.Join(err, fmt.Errorf("cleanup replacement after persistence failure: %w", cleanupErr))
|
|
}
|
|
return nil, err
|
|
}
|
|
m.copyConfiguredFiles(ctx, replacementReq, &replacement)
|
|
m.runWorktreeSetupScript(ctx, &replacement, replacementReq.ScriptEnv)
|
|
m.logger.Info("replaced unrecoverable worktree branch",
|
|
zap.String("session_id", replacement.SessionID),
|
|
zap.String("task_id", replacement.TaskID),
|
|
zap.String("original_branch", branchErr.BranchName()),
|
|
zap.String("new_branch", replacement.Branch),
|
|
zap.String("path", replacement.Path))
|
|
return &replacement, nil
|
|
}
|
|
|
|
// cleanupFailedReplacement removes the physical checkout and the branch that
|
|
// gitAddWorktree created before the replacement record could be persisted. The
|
|
// original record remains authoritative when this compensation runs.
|
|
func (m *Manager) cleanupFailedReplacement(ctx context.Context, replacement *Worktree) error {
|
|
if replacement == nil {
|
|
return nil
|
|
}
|
|
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), m.inspectTimeout)
|
|
defer cancel()
|
|
|
|
var cleanupErrs []error
|
|
if err := m.removeWorktreeDir(cleanupCtx, replacement.Path, replacement.RepositoryPath); err != nil {
|
|
cleanupErrs = append(cleanupErrs, fmt.Errorf("remove replacement worktree: %w", err))
|
|
}
|
|
if err := m.cleanupFailedReplacementBranch(cleanupCtx, replacement); err != nil {
|
|
cleanupErrs = append(cleanupErrs, err)
|
|
}
|
|
return errors.Join(cleanupErrs...)
|
|
}
|
|
|
|
func (m *Manager) cleanupFailedReplacementBranch(ctx context.Context, replacement *Worktree) error {
|
|
branchRef := "refs/heads/" + replacement.Branch
|
|
exists, err := m.branchExists(ctx, replacement.RepositoryPath, branchRef)
|
|
if err != nil {
|
|
return fmt.Errorf("verify replacement branch: %w", err)
|
|
}
|
|
if !exists {
|
|
return nil
|
|
}
|
|
output, err := m.runBoundedGitInspect(
|
|
ctx,
|
|
replacement.RepositoryPath,
|
|
"rev-parse",
|
|
"--verify",
|
|
branchRef+"^{commit}",
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("resolve replacement branch: %w", err)
|
|
}
|
|
_, err = deleteBranchRefIfOwned(ctx, replacement.RepositoryPath, newBranchAddSnapshot{
|
|
branchRef: branchRef,
|
|
branchOID: strings.TrimSpace(output),
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("delete replacement branch: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func replacementRequest(existing *Worktree, req CreateRequest) (CreateRequest, error) {
|
|
replacementReq := req
|
|
if replacementReq.BaseBranch == "" {
|
|
replacementReq.BaseBranch = existing.BaseBranch
|
|
}
|
|
if replacementReq.TaskDirName == "" {
|
|
replacementReq.TaskDirName = existing.TaskDirName
|
|
}
|
|
if replacementReq.TaskEnvironmentID == "" {
|
|
replacementReq.TaskEnvironmentID = existing.TaskEnvironmentID
|
|
}
|
|
if replacementReq.BaseBranch == "" || replacementReq.TaskDirName == "" || replacementReq.RepoName == "" {
|
|
return CreateRequest{}, fmt.Errorf("replace unrecoverable worktree: %w", ErrTaskDirRequired)
|
|
}
|
|
return replacementReq, nil
|
|
}
|
|
|
|
func replacementRecord(
|
|
existing *Worktree,
|
|
req CreateRequest,
|
|
branchName, worktreePath, fallbackWarning, fallbackDetail string,
|
|
) Worktree {
|
|
identitySlug := existing.BranchSlug
|
|
if identitySlug == "" {
|
|
identitySlug = requestBranchIdentitySlug(req)
|
|
}
|
|
replacement := *existing
|
|
replacement.TaskDirName = req.TaskDirName
|
|
replacement.TaskEnvironmentID = req.TaskEnvironmentID
|
|
replacement.BranchSlug = identitySlug
|
|
replacement.RepositoryPath = req.RepositoryPath
|
|
replacement.Path = worktreePath
|
|
replacement.Branch = branchName
|
|
replacement.BaseBranch = req.BaseBranch
|
|
replacement.Status = StatusActive
|
|
replacement.DeletedAt = nil
|
|
replacement.FetchWarning = ""
|
|
replacement.FetchWarningDetail = ""
|
|
replacement.BaseBranchFallbackWarning = fallbackWarning
|
|
replacement.BaseBranchFallbackDetail = fallbackDetail
|
|
replacement.UpdatedAt = time.Now()
|
|
if replacement.SessionID == "" {
|
|
replacement.SessionID = req.SessionID
|
|
}
|
|
if replacement.TaskID == "" {
|
|
replacement.TaskID = req.TaskID
|
|
}
|
|
return replacement
|
|
}
|
|
|
|
func (m *Manager) persistReplacementWorktree(ctx context.Context, replacement *Worktree) error {
|
|
if m.store != nil {
|
|
if err := m.store.UpdateWorktree(ctx, replacement); err != nil {
|
|
return fmt.Errorf("persist replacement worktree: %w", err)
|
|
}
|
|
}
|
|
if replacement.SessionID != "" {
|
|
m.mu.Lock()
|
|
m.worktrees[cacheKey(replacement.SessionID, replacement.RepositoryID, replacement.BranchSlug)] = replacement
|
|
m.mu.Unlock()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *Manager) replacementTarget(ctx context.Context, req CreateRequest) (string, string, error) {
|
|
_, branchBase := m.buildWorktreeNames(req)
|
|
for attempt := 0; attempt < replacementBranchAttempts; attempt++ {
|
|
branchName := replacementBranchName(branchBase, attempt)
|
|
exists, err := m.branchExists(ctx, req.RepositoryPath, branchName)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("check replacement branch %q: %w", branchName, err)
|
|
}
|
|
if exists {
|
|
continue
|
|
}
|
|
path, err := m.replacementPath(req, branchName)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
if _, err := os.Lstat(path); errors.Is(err, os.ErrNotExist) {
|
|
return branchName, path, nil
|
|
} else if err != nil {
|
|
return "", "", fmt.Errorf("inspect replacement worktree path: %w", err)
|
|
}
|
|
}
|
|
return "", "", fmt.Errorf("unable to find a unique replacement branch after %d attempts: %w", replacementBranchAttempts, ErrBranchExists)
|
|
}
|
|
|
|
func replacementBranchName(branchBase string, attempt int) string {
|
|
if attempt == 0 {
|
|
return branchBase
|
|
}
|
|
return branchBase + "-" + SmallSuffix(3)
|
|
}
|
|
|
|
func (m *Manager) replacementPath(req CreateRequest, branchName string) (string, error) {
|
|
pathReq := req
|
|
pathReq.BranchSlug = SanitizeBranchSlug(branchName)
|
|
pathReq.BranchIdentitySlug = requestBranchIdentitySlug(req)
|
|
path, err := m.prepareTaskWorktreePath(pathReq)
|
|
if err != nil {
|
|
return "", fmt.Errorf("prepare replacement worktree path: %w", err)
|
|
}
|
|
return path, nil
|
|
}
|