Files
kandev/apps/backend/internal/worktree/manager_cleanup.go
T
João Salavisa de334186bc fix(worktree): rmdir empty task parent after worktree removal (#1267)
* fix(worktree): rmdir empty task parent after worktree removal (#1266)

removeWorktreeDir tore down only the inner {repoName}/ subdirectory under
{tasksBase}/{taskDirName}/, leaving the now-empty parent task directory
behind on every archive, delete, and rollback path. Husks accumulated
under ~/.kandev/tasks/ over time.

Now also attempts rmdir on the parent when it is a direct child of
TasksBasePath. os.Remove no-ops if the parent still has siblings or
workspace-scoped content, so multi-branch siblings and the tasksBase
itself are preserved.

* fix(worktree): normalize tasksBase path + cover multi-branch sibling case

Address PR #1267 review feedback:

- Apply filepath.Clean to ExpandedTasksBasePath() before comparing against
  filepath.Dir(parent). A configured TasksBasePath with a trailing slash
  or doubled separators previously fell through the equality guard,
  silently re-introducing the issue-1266 husk for those users.
- Use cfg.ExpandedTasksBasePath() in the regression test for consistency
  with production code.
- Add two test cases:
  - multi-branch sibling worktrees share a parent; removing one preserves
    the parent, removing both clears it.
  - trailing-slash TasksBasePath still triggers cleanup.

* test(worktree): check ExpandedTasksBasePath error in tasksbase survival assert

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-06-04 01:19:57 +01:00

310 lines
10 KiB
Go

package worktree
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"go.uber.org/zap"
)
// RemoveByID removes a specific worktree by its ID and optionally its branch.
func (m *Manager) RemoveByID(ctx context.Context, worktreeID string, removeBranch bool) error {
wt, err := m.GetByID(ctx, worktreeID)
if err != nil {
return err
}
return m.removeWorktree(ctx, wt, removeBranch)
}
// removeWorktree performs the actual removal of a worktree.
func (m *Manager) removeWorktree(ctx context.Context, wt *Worktree, removeBranch bool) error {
// Get repository lock
repoLock := m.getRepoLock(wt.RepositoryPath)
repoLock.Lock()
defer func() {
repoLock.Unlock()
m.releaseRepoLock(wt.RepositoryPath)
}()
// Execute cleanup script BEFORE removing directory
m.runWorktreeCleanupScript(ctx, wt)
// Remove worktree directory
if err := m.removeWorktreeDir(ctx, wt.Path, wt.RepositoryPath); err != nil {
m.logger.Warn("failed to remove worktree directory",
zap.String("path", wt.Path),
zap.Error(err))
}
// Optionally remove the branch from the main repository
if removeBranch {
m.logger.Info("deleting branch from main repository",
zap.String("branch", wt.Branch),
zap.String("repository_path", wt.RepositoryPath))
cmd := exec.CommandContext(ctx, "git", "branch", "-D", wt.Branch)
cmd.Dir = wt.RepositoryPath
if output, err := runGitCmdCombinedOutput(ctx, cmd); err != nil {
m.logger.Warn("failed to delete branch from main repository",
zap.String("branch", wt.Branch),
zap.String("repository_path", wt.RepositoryPath),
zap.String("output", string(output)),
zap.Error(err))
} else {
m.logger.Info("successfully deleted branch from main repository",
zap.String("branch", wt.Branch),
zap.String("repository_path", wt.RepositoryPath))
}
}
// Update store
if m.store != nil {
now := time.Now()
wt.Status = StatusDeleted
wt.DeletedAt = &now
wt.UpdatedAt = now
if err := m.store.UpdateWorktree(ctx, wt); err != nil {
// Record may already be deleted by another cleanup path (e.g. task deletion).
// This is expected and harmless - only log at debug level.
m.logger.Debug("failed to update worktree status (may already be deleted)",
zap.String("worktree_id", wt.ID),
zap.Error(err))
}
}
// Update cache: delete the (session, repo) entry. Removing a worktree only
// affects its own repo; siblings on other repos must remain cached.
m.mu.Lock()
if wt.SessionID != "" {
delete(m.worktrees, cacheKey(wt.SessionID, wt.RepositoryID, wt.BranchSlug))
}
m.mu.Unlock()
m.logger.Info("removed worktree",
zap.String("task_id", wt.TaskID),
zap.String("worktree_id", wt.ID),
zap.String("path", wt.Path),
zap.Bool("branch_removed", removeBranch))
return nil
}
// runWorktreeSetupScript runs the repository's setup script in the freshly
// created worktree. Setup script failures are non-fatal: the worktree is kept
// and the failure is recorded on wt as a warning (surfaced by the env preparer)
// so the agent can still launch. This mirrors the task-level setup script
// behavior in runSetupScriptStep — a broken setup script must not block the task.
func (m *Manager) runWorktreeSetupScript(ctx context.Context, wt *Worktree) {
if m.scriptMsgHandler == nil || m.repoProvider == nil {
return
}
if wt.RepositoryID == "" {
// Nothing to set up without a linked repository; upstream may not
// always populate this field.
return
}
repo, err := m.repoProvider.GetRepository(ctx, wt.RepositoryID)
if err != nil {
m.logger.Warn("failed to fetch repository for setup script",
zap.String("repository_id", wt.RepositoryID),
zap.Error(err))
return
}
if strings.TrimSpace(repo.SetupScript) == "" {
return
}
m.logger.Info("executing setup script for worktree",
zap.String("worktree_id", wt.ID),
zap.String("repository_id", wt.RepositoryID))
scriptReq := ScriptExecutionRequest{
SessionID: wt.SessionID,
TaskID: wt.TaskID,
RepositoryID: wt.RepositoryID,
Script: repo.SetupScript,
WorkingDir: wt.Path,
ScriptType: "setup",
}
if err := m.scriptMsgHandler.ExecuteSetupScript(ctx, scriptReq); err != nil {
// Non-fatal: keep the worktree and surface a warning. The detailed
// script output is already streamed to the script_execution chat
// message; here we only record a concise, user-facing warning.
m.logger.Warn("setup script failed, continuing without it",
zap.String("worktree_id", wt.ID),
zap.Error(err))
wt.SetupScriptWarning = "Repository setup script failed; the worktree was created without it. Fix the setup script and re-run if needed."
wt.SetupScriptWarningDetail = err.Error()
return
}
m.logger.Info("setup script completed successfully", zap.String("worktree_id", wt.ID))
}
// runWorktreeCleanupScript executes the repository cleanup script for a worktree before removal.
func (m *Manager) runWorktreeCleanupScript(ctx context.Context, wt *Worktree) {
if m.scriptMsgHandler == nil || m.repoProvider == nil {
return
}
repo, err := m.repoProvider.GetRepository(ctx, wt.RepositoryID)
if err != nil {
m.logger.Warn("failed to fetch repository for cleanup script",
zap.String("repository_id", wt.RepositoryID),
zap.Error(err))
return
}
if strings.TrimSpace(repo.CleanupScript) == "" {
return
}
m.logger.Info("executing cleanup script for worktree",
zap.String("worktree_id", wt.ID),
zap.String("repository_id", wt.RepositoryID))
scriptReq := ScriptExecutionRequest{
SessionID: wt.SessionID,
TaskID: wt.TaskID,
RepositoryID: wt.RepositoryID,
Script: repo.CleanupScript,
WorkingDir: wt.Path,
ScriptType: "cleanup",
}
if err := m.scriptMsgHandler.ExecuteCleanupScript(ctx, scriptReq); err != nil {
m.logger.Warn("cleanup script failed, proceeding with deletion",
zap.String("worktree_id", wt.ID),
zap.Error(err))
} else {
m.logger.Info("cleanup script completed successfully",
zap.String("worktree_id", wt.ID))
}
}
// CleanupWorktrees removes provided worktrees without re-fetching from the store.
func (m *Manager) CleanupWorktrees(ctx context.Context, worktrees []*Worktree) error {
if len(worktrees) == 0 {
return nil
}
var lastErr error
for _, wt := range worktrees {
if wt == nil {
continue
}
if err := m.removeWorktree(ctx, wt, true); err != nil {
m.logger.Warn("failed to remove worktree on task deletion",
zap.String("task_id", wt.TaskID),
zap.String("worktree_id", wt.ID),
zap.Error(err))
lastErr = err
}
}
m.mu.Lock()
for _, wt := range worktrees {
if wt == nil {
continue
}
if wt.SessionID != "" {
delete(m.worktrees, cacheKey(wt.SessionID, wt.RepositoryID, wt.BranchSlug))
}
}
m.mu.Unlock()
return lastErr
}
// OnTaskDeleted cleans up all worktrees for a task when it is deleted.
func (m *Manager) OnTaskDeleted(ctx context.Context, taskID string) error {
// Get all worktrees for this task
worktrees, err := m.GetAllByTaskID(ctx, taskID)
if err != nil {
return err
}
return m.CleanupWorktrees(ctx, worktrees)
}
// removeWorktreeDir removes a worktree directory using git worktree remove.
// After the inner directory is gone it tries to rmdir the parent task
// directory left behind by the nested {tasksBase}/{taskDirName}/{repoName}
// layout (see issue #1266). The rmdir is a best-effort no-op if the parent
// still has siblings or contains workspace-scoped content.
func (m *Manager) removeWorktreeDir(ctx context.Context, worktreePath, repoPath string) error {
// First try git worktree remove
cmd := exec.CommandContext(ctx, "git", "worktree", "remove", "--force", worktreePath)
cmd.Dir = repoPath
if output, err := runGitCmdCombinedOutput(ctx, cmd); err != nil {
m.logger.Debug("git worktree remove failed, falling back to rm",
zap.String("output", string(output)),
zap.Error(err))
if err := m.forceRemoveDir(ctx, worktreePath); err != nil {
return err
}
// Prune stale worktree entries
pruneCmd := exec.CommandContext(ctx, "git", "worktree", "prune")
pruneCmd.Dir = repoPath
if err := runGitCmd(ctx, pruneCmd); err != nil {
m.logger.Debug("git worktree prune failed", zap.Error(err))
}
}
m.tryRemoveEmptyTaskDir(worktreePath)
return nil
}
// tryRemoveEmptyTaskDir rmdirs the parent of a removed worktree if that
// parent is an immediate child of TasksBasePath (i.e. a per-task container
// directory) and is now empty. Silently skips otherwise.
func (m *Manager) tryRemoveEmptyTaskDir(worktreePath string) {
tasksBase, err := m.config.ExpandedTasksBasePath()
if err != nil || tasksBase == "" {
return
}
// Normalize both sides: ExpandedTasksBasePath returns the configured
// value verbatim for absolute paths (incl. trailing slashes or doubled
// separators), while filepath.Dir always yields a cleaned form.
tasksBase = filepath.Clean(tasksBase)
parent := filepath.Dir(worktreePath)
// Guard: only act on direct children of tasksBase. Never touch
// tasksBase itself or any deeper / unrelated location.
if filepath.Dir(parent) != tasksBase {
return
}
if err := os.Remove(parent); err != nil && !os.IsNotExist(err) {
m.logger.Debug("task dir not removed (likely non-empty)",
zap.String("path", parent),
zap.Error(err))
}
}
// forceRemoveDir removes a directory, retrying on transient failures.
// On macOS, os.RemoveAll can fail with "directory not empty" when files
// have special attributes or were recently released by other processes
// (e.g. .next/dev build cache). Falls back to rm -rf as a last resort.
func (m *Manager) forceRemoveDir(ctx context.Context, dir string) error {
const maxRetries = 3
const retryDelay = 200 * time.Millisecond
for i := range maxRetries {
err := os.RemoveAll(dir)
if err == nil {
return nil
}
if i < maxRetries-1 {
m.logger.Debug("os.RemoveAll failed, retrying",
zap.String("path", dir),
zap.Int("attempt", i+1),
zap.Error(err))
time.Sleep(retryDelay)
}
}
// Last resort: shell out to rm -rf which handles macOS edge cases better
cmd := exec.CommandContext(ctx, "rm", "-rf", dir)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("rm -rf failed: %w (output: %s)", err, string(output))
}
return nil
}