worktree

package
v0.0.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 27, 2026 License: MIT Imports: 12 Imported by: 0

Documentation

Overview

Package worktree manages git worktrees for isolating parallel task execution.

When cloop runs multiple tasks in parallel under git mode, each task gets its own git worktree in .cloop/worktrees/task-<id>/ on a dedicated branch (cloop/task-<id>-<slug>). Tasks can modify files independently without stepping on each other; their changes are later merged back to the base branch sequentially via pkg/mergequeue.

Index

Constants

View Source
const DefaultMinAge = 2 * time.Hour

DefaultMinAge is the age below which Prune refuses to touch a worktree when the caller did not choose one.

It is a compromise between two failure modes with very different costs. Too low and a sweep can delete the tree of a task that is merely thinking — an agent waiting on a slow model call writes nothing for minutes at a time, and the work it loses is unrecoverable. Too high and leaked directories sit around longer, which costs disk. Two hours is long enough to cover any plausible quiet period inside a running task and short enough that a daily sweep still collects yesterday's crash.

Variables

This section is empty.

Functions

func BranchName

func BranchName(task *pm.Task) string

BranchName returns the branch name used for a task worktree.

func IsGitRepo

func IsGitRepo(dir string) bool

IsGitRepo reports whether dir is inside a git repository (working tree or linked worktree). Useful as a guard before invoking worktree machinery.

func ListBranches

func ListBranches(repoDir string) ([]string, error)

ListBranches returns the cloop/task-<id>-* branches of repoDir, ordered by task ID.

for-each-ref rather than `git branch --list`: the latter decorates the current branch with an asterisk and indents the rest, which is a display format that has changed before and would silently start yielding branch names with a leading "* " if it changed again.

func Path

func Path(repoDir string, task *pm.Task) string

Path returns the absolute path to the worktree directory for a task. The directory lives under <repoDir>/.cloop/worktrees/task-<id>.

func WorktreesDir

func WorktreesDir(repoDir string) string

WorktreesDir is the directory this package owns: the only place Prune will remove anything from. Exported so a caller wiring a startup sweep can log it (and so a test can assert that Prune's blast radius is what it claims).

Types

type Entry

type Entry struct {
	// Path is the absolute path of the worktree directory, whether or not it
	// still exists.
	Path string
	// Branch is the branch checked out there, without the refs/heads/ prefix.
	// Empty when git no longer registers the worktree and no single
	// cloop/task-<id>-* branch could be matched to it — see List for why an
	// ambiguous match is deliberately left empty rather than guessed.
	Branch string
	// TaskID is the cloop task this worktree was created for, parsed from the
	// directory name or, failing that, from the branch. Zero means the entry
	// could not be attributed to a task, which makes it invisible to
	// PruneOptions.Active and therefore protected only by MinAge.
	TaskID int
	// DirExists reports whether the directory is on disk.
	DirExists bool
	// Registered reports whether `git worktree list` still knows about it.
	Registered bool
	// Locked reports that the worktree carries git's own lock marker
	// (`git worktree lock`), which is an operator saying "do not collect this".
	// Prune honours it unconditionally.
	Locked bool
	// LockReason is the message passed to `git worktree lock`, if any.
	LockReason string
	// ModTime is the most recent modification observed for this worktree. It is
	// a heuristic, not a liveness signal: see entryModTime.
	ModTime time.Time
}

Entry is one cloop-managed worktree, as reconciled from git's registration and from the directory on disk. Either source alone can be missing; see the file header for why both cases matter.

func List

func List(repoDir string) ([]Entry, error)

List enumerates the cloop-managed worktrees of repoDir, reconciling git's registrations with the contents of .cloop/worktrees.

Only entries under <repoDir>/.cloop/worktrees are returned. That containment rule is what keeps the main worktree — and any worktree the operator created by hand elsewhere — out of a sweep's reach: `git worktree list` reports the main working tree first and reports every linked worktree regardless of where it lives, so filtering by branch name alone would happily list (and Prune would then happily delete) the checkout the operator is sitting in, if they happened to have a cloop/task-N branch checked out there.

A failure of `git worktree list` is fatal rather than degraded. The alternative — falling back to the directory scan — would report every registered worktree as unregistered, which is not a partial answer but a wrong one, and Prune acts on it.

func (Entry) String

func (e Entry) String() string

String renders an entry for a log line.

type KeepCode

type KeepCode string

KeepCode is the machine-readable reason a worktree was spared. Callers should switch on this rather than parse KeptEntry.Reason, which is prose for humans.

const (
	// KeepActive: the caller told us this task is running.
	KeepActive KeepCode = "active"
	// KeepTooYoung: the worktree is younger than PruneOptions.MinAge, or its
	// age could not be determined at all.
	KeepTooYoung KeepCode = "too-young"
	// KeepLocked: `git worktree lock` was used on it.
	KeepLocked KeepCode = "locked"
)

type KeptEntry

type KeptEntry struct {
	Entry
	Code   KeepCode
	Reason string
}

KeptEntry is a worktree the sweep deliberately left alone.

type PruneError

type PruneError struct {
	Path   string
	TaskID int
	Err    error
}

PruneError is one entry's failure. The sweep records it and moves on.

func (*PruneError) Error

func (e *PruneError) Error() string

func (*PruneError) Unwrap

func (e *PruneError) Unwrap() error

type PruneOptions

type PruneOptions struct {
	// DryRun computes and reports the whole plan without changing anything.
	// The merged-branch check still runs, because it is read-only, so a dry run
	// reports the branch deletions a real run would attempt. It cannot report
	// the outcome of git's own second gate — `git branch -d` applies a stricter
	// merged test of its own, against HEAD rather than against BaseBranch — so
	// a real run can keep a branch a dry run listed for deletion. It can never
	// go the other way and delete one a dry run did not list.
	DryRun bool

	// MinAge is the single most important safety property in this package.
	//
	// A live parallel run's worktrees are in this directory *right now*, and
	// pruning one destroys in-flight, uncommitted work that no git operation
	// can bring back. Nothing younger than MinAge is touched under any
	// circumstance.
	//
	// Zero means DefaultMinAge — the zero value of this struct must be safe,
	// because it is what a caller writes when they have not thought about it.
	// A *negative* value disables the guard entirely, which is deliberately
	// something a caller has to type on purpose; it is meant for tests and for
	// an operator who has already established that nothing is running.
	MinAge time.Duration

	// Active names the task IDs that are executing right now. This is the
	// authoritative liveness signal and MinAge is only the backstop for tasks
	// the caller has lost track of: a caller that has a task table should pass
	// it, because mtime cannot distinguish "finished an hour ago" from "still
	// running and quiet for an hour".
	Active map[int]bool

	// KeepTaskIDs is the same instruction in slice form, for callers that have
	// a list rather than a set. The two are unioned; neither overrides the
	// other.
	KeepTaskIDs []int

	// DeleteBranches also removes each collected worktree's cloop/task-N-*
	// branch — but only when that branch's commits are already contained in
	// BaseBranch. An unmerged branch is the only copy of its task's work, so it
	// is kept and the reason recorded. Off by default.
	DeleteBranches bool

	// BaseBranch is the branch a task branch must be merged into before
	// DeleteBranches will remove it. Empty means the current branch of repoDir,
	// which is the branch a task worktree was created from and merged back to.
	BaseBranch string
}

PruneOptions configures a sweep. The zero value is safe: it prunes nothing younger than DefaultMinAge and deletes no branches at all.

type PruneResult

type PruneResult struct {
	// DryRun echoes the option, so a caller holding only the result can tell
	// whether Removed describes the past or a plan.
	DryRun bool
	// Removed lists the worktrees collected, or that would be.
	Removed []PrunedEntry
	// Kept lists the worktrees spared, each with the reason.
	Kept []KeptEntry
	// Errors lists per-entry failures. A non-empty Errors does not mean the
	// sweep failed; it means these entries were skipped and the rest were done.
	Errors []*PruneError
}

PruneResult reports what a sweep did, what it declined to do, and why.

It reports kept entries as prominently as removed ones on purpose: the question an operator actually has after a sweep is "why is this directory still full", and a result that only listed successes could not answer it.

func Prune

func Prune(repoDir string, opts PruneOptions) (*PruneResult, error)

Prune removes leaked cloop worktrees from repoDir and, when asked, their merged task branches.

The only fatal errors are the ones that make the whole sweep meaningless: a repoDir that is not a git repository, or a `git worktree list` that fails. A failure on one entry is recorded in PruneResult.Errors and the sweep continues, because a single worktree wedged by an open file handle or a permission error must not stop the other twenty from being collected.

func (*PruneResult) Summary

func (r *PruneResult) Summary() string

Summary renders a one-line report for a startup log.

type PrunedEntry

type PrunedEntry struct {
	Entry
	// BranchDeleted reports whether the task branch was deleted too.
	BranchDeleted bool
	// BranchKept explains why it was not, and is empty when it was deleted or
	// when DeleteBranches was off. The common value is "not merged", which is
	// the API refusing to destroy the only copy of a task's work.
	BranchKept string
}

PrunedEntry is a worktree that was removed, or that a dry run would remove.

type Worktree

type Worktree struct {
	// Path is the absolute path to the worktree directory.
	Path string
	// Branch is the branch checked out in the worktree.
	Branch string
	// BaseBranch is the branch the worktree was created from (the merge target).
	BaseBranch string
	// TaskID is the cloop task ID this worktree was created for.
	TaskID int
}

Worktree represents a single live git worktree for a task.

func Create

func Create(repoDir string, task *pm.Task) (*Worktree, error)

Create provisions a fresh worktree for the given task at .cloop/worktrees/task-<id> off the current HEAD of repoDir. If a stale worktree exists at that path it is removed first (git worktree remove --force). The created branch is cloop/task-<id>-<slug>; if a branch by that name already exists it is reset to the current HEAD so the worktree always starts from a clean baseline.

func (*Worktree) Commit

func (w *Worktree) Commit(task *pm.Task) (string, error)

Commit stages all changes in the worktree and commits them with a task-scoped message. Returns the new commit SHA. If the worktree is clean, returns "" and a nil error (no-op).

func (*Worktree) HasChanges

func (w *Worktree) HasChanges() (bool, error)

HasChanges reports whether the worktree contains any uncommitted or untracked changes. Used by callers to decide whether a merge is even meaningful.

func (*Worktree) Remove

func (w *Worktree) Remove(repoDir string) error

Remove tears down the worktree directory and prunes git's bookkeeping. The underlying branch is left intact so merge history (and any unmerged work) remains recoverable. Errors are returned but callers usually treat them as non-fatal cleanup failures.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL