mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-10 10:26:46 +08:00
* feat(daemon): worktree mode for local_directory resources (MUL-5707) Local directory resources serialise every task on a per-path mutex, so two tasks on one folder queue behind each other. This adds an opt-in worktree mode: each task gets its own git worktree of the user's repo inside the daemon's env root, so tasks run concurrently and deliver their work as a branch instead of editing the user's working copy. Opt in per resource with execution_mode=worktree (default stays in_place, so existing resources are unchanged). Requires a git repo with at least one commit; a non-git directory fails with an actionable message rather than silently falling back to serial execution. Design points that make the mode safe to use: - The agent starts from what the user sees, not HEAD. Tracked edits are captured with `git stash create` (which writes a commit object without touching the index, working tree, or stash list) and replayed into the worktree; untracked non-ignored files are copied, bounded at 2000 files / 200MB with the skipped count logged at warn level. - That replayed state is committed as a baseline, so "did this task change anything?" has an exact answer and `git diff <baseline>..<branch>` is precisely the agent's work. - Nothing is silently discarded: leftovers are committed to the branch before the worktree is removed, including when the task fails partway. - A task that changes nothing leaves no branch behind. - Sidecars stay out of the branch by running the existing CleanupRuntimeConfig + CleanupSidecars pass over the worktree before finalizing. The .git/info/exclude approach repocache uses is not usable here: a linked worktree resolves info/exclude to the user's own common git dir, so it would change what `git status` hides in their checkout. - Worktree-mode env roots do not get the local_directory GC exemption, so they age out normally instead of accumulating one env root per task. - `git worktree prune` on create self-heals registrations orphaned by a crashed daemon. Adds --execution-mode to `project resource add/update`, and updates the docs and the projects-and-resources built-in skill. Co-authored-by: multica-agent <[email protected]> * fix(daemon): close data-loss and isolation gaps in worktree mode (MUL-5707) Review follow-up on #6759. Six issues, all on failure or skew paths. 1. Preserve work when the final commit fails. Finalize logged a warning and then ran `git worktree remove --force`, destroying the agent's changes. commit.gpgSign with no key available to the daemon triggers this, and --no-verify does not disable signing. Finalize now returns an error and deliberately keeps the worktree — it stays registered, so `git worktree list` points at the work — and the daemon fails the task instead of reporting success with a branch that is missing the changes. 2. Fail closed on an unsupported execution_mode. Treating an unknown mode as in_place was wrong: execution_mode is how a user asks for ISOLATION, so the "safe" fallback would edit the working copy they asked us to stay out of. An unrecognised mode now fails the task naming the version skew. 3. Keep Codex conversations alive across turns. The per-issue session store was keyed on LocalWorkDir != "", so worktree tasks — which also never reuse an env root — got a fresh empty CODEX_HOME per turn and silently lost the thread. Covered by a two-turn regression test that fails without the fix. 4. Fail closed on an incomplete snapshot. A failed stash create/apply, a failed untracked copy, or hitting the copy bounds all degraded to "agent sees HEAD" with only a daemon log line. A half-replayed tree looks like a working checkout, so nothing downstream questions it while the agent reads different code than the user has. All of these now abort the prepare and tear the partial worktree back down. 5. Do not snapshot a directory mid-mutation. The same real path can be attached to another project as an in_place resource; snapshotting under a running in_place task captured a half-written tree plus that task's in-flight .agent_context/.multica sidecars, which then reached the branch and could feed the agent another issue's brief. Worktree prepare now takes the per-path mutex for the snapshot alone (released before the run, so tasks still overlap), and the untracked replay skips Multica's own sidecars. 6. Stop the test helper from masking regressions. gitRun skipped on any git failure, including post-finalize assertions, so a lost branch could report as a skip. Git availability is now checked once at repo setup; every later git failure is fatal. Also rebased onto main, keeping both MulticaConfigRoot and LocalWorktree in the Environment initialiser. Co-authored-by: multica-agent <[email protected]> * fix(daemon): close sidecar-commit and wait-visibility gaps in worktree mode; gate worktree saves on daemon version (MUL-5707) - runTask: register the sidecar-cleanup defer immediately after the Finalize defer. Previously two early returns (temp-dir setup, StartTask failure) sat between them, and on those paths Finalize auto-committed the sidecars Prepare had just written — delivering a branch whose only content is Multica's own runtime files (in_place: leaving them in the user's tree). - runTask: give the worktree snapshot wait the same plumbing as the in-place acquire — waiting_local_directory status, resourceWaitTasks accounting, and a cancellation poller. The holder can be an hours-long in_place task; the wait was invisible and a user cancel went unnoticed until the 5-minute prepare timeout. - isMulticaSidecarPath: match sidecar dirs as whole path segments at any depth, not only at the repo root. An in_place resource pointed at a SUBDIRECTORY leaves its sidecars below that subdirectory; replaying them committed another issue's brief into the delivered branch. - handler: refuse saving execution_mode=worktree while the owning daemon's registered cli_version is below MinLocalWorktreeCLIVersion (422, daemon_version_unsupported). An old daemon json-skips the field and runs tasks IN PLACE — the daemon-side unknown-mode refusal cannot protect a daemon that predates the field, so the server has to. Residual (documented): a daemon downgraded after the save needs a claim-time gate. - copyUntrackedFiles: read the -z listing raw instead of TrimSpace-ing it, which corrupted filenames beginning/ending with whitespace into paths that silently vanished from the replay. - docs + skill: describe the save-time version gate; drop the stale claim that old runtimes fail the task themselves. Co-authored-by: multica-agent <[email protected]> * feat(views): UI switch for local_directory execution mode (MUL-5707) Worktree mode shipped CLI/API-only, which for its target user — someone running several tasks at once from Desktop — means it does not exist. This adds the switch to the resources panel. The choice is framed by what the user gets back, not by the mode identifiers: "Edit this folder directly" (one task at a time, changes land in your working copy) vs "Run in parallel, isolated" (tasks run at the same time and each hands back an agent/… branch). `in_place` / `worktree` stay visible as a secondary hint for anyone cross-referencing the CLI or docs. Two entry points, matching where the decision is actually made: - The add-a-folder flow now asks for the mode after the folder is picked and validated, instead of creating the resource and leaving the mode to be discovered later. - Every local_directory row has a mode control. It is deliberately NOT gated on being the owning machine — switching the mode only rewrites a field, so it works from the web app too, unlike rename or the folder picker. Both blocked states are handled where the user chooses, not after they commit: - Non-git folder: the desktop validator now reports whether the path is inside a git working tree (walking up for `.git`, so a subdirectory of a repo counts), and the option is disabled with that reason. Unknown — an older desktop build, or an existing row — stays permissive; the daemon re-checks authoritatively. - Daemon too old: the version floor is mirrored from the server as MIN_LOCAL_WORKTREE_CLI_VERSION and checked against the runtime for the resource's OWN daemon, not the machine the browser happens to be on. A server rejection is rendered inline in the dialog rather than as a toast, so the dialog stays actionable. A "Parallel" badge on the row is always visible (not hover-only): without it there is no way to tell whether tasks on a folder edit it directly or hand back a branch, which is the first thing anyone asks when a task queues. Also adds `execution_mode` to LocalDirectoryResourceRef — the field existed server-side but had no TypeScript counterpart — and brings the zh/ja/ko project-resources docs in line with the English ones, which were the only locale updated when the mode landed. Co-authored-by: multica-agent <[email protected]> * fix(daemon,handler): close worktree isolation and delivery gaps (MUL-5707) Second review round. Five must-fix items, all on paths where the mode's promises could still break. 1. A failed sidecar cleanup no longer ships Multica's own files as the user's branch. The cleanup errors were logged and Finalize then ran `git add -A`, so a permission/disk/lock failure could deliver a branch whose entire content was the runtime brief and issue context. Worktree mode now escalates a cleanup failure to an abort: nothing is committed, the worktree is kept for inspection, and the task fails with the preserved path. In place it stays a warning — no commit, no branch, and the leftovers are visible in the user's own tree. 2. The untracked replay no longer drops files silently. `Lstat` failures and symlinks were `continue`d without counting, so the caller read a partial snapshot as complete. Both now count as skipped, which the existing fail-closed check turns into a refused prepare. Non-content entries (sockets, FIFOs, devices) stay uncounted deliberately. 3. Prepare rolls back a worktree it created. Any later failure — context files, provider home, MCP config — returned without an Environment, so nothing downstream knew a worktree existed, leaving a registration and a branch no task ever ran in. A deferred Discard now runs unless Prepare reached its successful return. 4. The daemon-version gate moved to where it can actually hold. Save-time validation cannot see a machine downgraded afterwards, and an old daemon json-skips execution_mode entirely — it would run the task in place, editing the working copy the user asked to isolate. Claims are now checked against the runtime that is actually claiming, and a blocked task is cancelled with a reason rather than left to be redelivered to the same too-old runtime forever. 5. branch_name is delivered end to end. The daemon sent it, but TaskCompleteRequest had no field so it vanished at decode, and the failure path never sent it at all. It now has a column, travels on both terminal callbacks, and renders as a copyable row in Run details. The failure path matters most: worktree mode commits the agent's leftovers before tearing the worktree down, so a run that died partway still has work worth finding. Docs (all four locales) and the projects-and-resources skill are updated for the claim-time gate and the branch surface. Co-authored-by: multica-agent <[email protected]> * fix(handler,daemon): migration renumber, cancel reasons, branch on every terminal path (MUL-5707) Third review round. 1. Rebased onto main and renumbered the migration to 285 — main had taken 273 and is now at 284. sqlc regenerated on top of the rebased queries; the conflicts were confined to generated files. 2. The claim gate now persists why it cancelled. It called CancelTask, which writes only status='cancelled', and returned the reason as a 4xx that reaches the daemon's log and nothing else — batch claim drops the task from its response entirely. A run refused for a durable reason the user has to act on cannot present as a bare "cancelled", so this adds CancelAgentTaskWithReason and records error + failure_reason on the row. 3. branch_name now survives every terminal path, not just the two direct callbacks. Three were dropping it: - the context-exhausted complete→fail reroute did not copy it; - the daemon's complete→fail fallback (server rejected the complete, agent had succeeded) did not set it; - both cancellation paths discarded the result wholesale, even though the worktree is finalized — and its work committed — before the daemon learns of the cancel. The branch now rides the cancel-ack and is recorded by SetAgentTaskBranchName, which never overwrites a name a terminal callback already wrote. 4. Rollout/rollback compatibility. Isolation is enforced by two server-side gates, so a daemon downgraded at any point after the resource was saved is caught. The gap they cannot close is rolling the SERVER back past this change while sub-floor daemons are still connected: an old server has no gate and an old daemon ignores execution_mode. That constraint is now documented where an operator will meet it (the migration and the docs), and the version floor is pinned across server and frontend by test so the two gates cannot silently drift apart. Co-authored-by: multica-agent <[email protected]> * fix(server,views): route claim-gate cancels through TaskService, make cancel reasons visible, harden cancel-ack branch delivery (MUL-5707) Closes the fourth-round review findings on worktree mode's cancellation chain: - Claim gate: cancel through a new TaskService.CancelTaskWithReason instead of a raw CancelAgentTaskWithReason query, so a gate-cancelled task gets the full cancellation side effects — audit capture, chat settle, agent status reconcile, task:cancelled broadcast, NotifyTaskFinished. Added REAL claim integration tests: singular claim (422 + row cancelled with reason), batch claim (task omitted + row cancelled with reason), and a current-runtime control. - Cancel reasons are now user-visible: execution log rows, agent activity rows, and the transcript dialog label a SERVER-cancelled task (persisted error/failure_reason) like a failed one, with the actionable text on hover and a Reason row in Run details. User-initiated cancels stay a plain "Cancelled". New cancelReasonLabel helper + local_directory_error label; details_reason locale key in en/zh-Hans/ja/ko; widened AgentTask.failure_reason to the open string the wire actually carries. - Cancel-ack is now a terminal delivery: the daemon retries it on transient errors (same schedule as complete/fail), the server fails LOUD (5xx) when persisting the branch/error fails instead of warn-and-200, and re-broadcasts task:cancelled after a successful write so clients that already refetched a branchless row pick up the delivery. Replays are idempotent (never overwrite). - Cancel + Finalize-abort no longer swallows the preserved-worktree pointer: the finalize error is wrapped in worktreePreservedError (joined onto an existing run error, never displacing it), extracted on the cancel path, and delivered via the ack into agent_task_queue.error through the new SetAgentTaskErrorIfEmpty query — never overwriting a reason recorded by a fail callback or the claim gate. Other run errors on a cancelled run stay discarded as before: they are expected noise. - Docs: the self-hosted rollback constraint callout now exists in zh/ja/ko, not just English. Co-authored-by: multica-agent <[email protected]> * fix(handler): guard cancel-ack writes to cancelled rows; requeue when the claim gate's cancel fails (MUL-5707) Fifth-round review fixes: - SetAgentTaskBranchName / SetAgentTaskErrorIfEmpty now carry a status='cancelled' CAS. The daemon acks on EVERY terminal status it observes, so a late or replayed ack from a stale run could previously backfill its branch or preserved-path error into a completed/failed row whose own callback — the authoritative channel — recorded nothing, without any event invalidating the change. A CAS-refused write is a deliberate no-op (still 200: nothing to retry), and the rebroadcast stays guarded by the same status check. Regression tests: a late ack against a completed row with empty fields and against a failed row leaves both untouched. - When the worktree claim gate's CancelTaskWithReason fails, the claimed row no longer strands in dispatched (daemon refused, nothing to release it but stale reclaim). The gate requeues via RequeueTaskAfterClaimFailure — the next claim re-runs the gate and retries the cancel — and reports 5xx so the singular daemon reads a transient server problem, not a claim refusal; the batch path omits the task as before, now with the row requeued. Fault injection tests (row-scoped trigger raising on the cancel UPDATE while letting the requeue through) cover singular and batch. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]>
745 lines
32 KiB
Go
745 lines
32 KiB
Go
package execenv
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Local worktree mode gives every task on a local_directory resource its own
|
|
// git worktree of the user's repo, created inside the daemon-owned env root.
|
|
// Tasks on the same directory then run concurrently instead of queueing on the
|
|
// per-path mutex, and each one delivers its work as a branch in the user's own
|
|
// repo — discoverable with `git branch`, no new result channel needed.
|
|
//
|
|
// Three properties this file exists to guarantee:
|
|
//
|
|
// 1. The agent sees what the user sees. `git worktree add` alone would check
|
|
// out HEAD, silently hiding the user's uncommitted work. We replay the
|
|
// dirty state into the worktree instead (tracked edits via a stash commit,
|
|
// untracked files by copy).
|
|
// 2. The user's directory is never written to. Everything — including the
|
|
// sidecar context files Prepare writes — lands inside the worktree, which
|
|
// is disposable. The only lasting effect on the user's repo is the branch.
|
|
// 3. Nothing is silently discarded. Whatever the agent leaves uncommitted is
|
|
// committed to the branch before the worktree goes away.
|
|
|
|
const (
|
|
// localWorktreeDirName is the env-root-relative directory holding the
|
|
// worktree. Kept short: on Windows the worktree path plus the deepest
|
|
// repo path must stay under MAX_PATH for tools that predate long paths.
|
|
localWorktreeDirName = "worktree"
|
|
|
|
// gitTimeout bounds every git invocation this file makes. These are all
|
|
// local-only operations (no network), so a slow one means a wedged index
|
|
// lock rather than a slow remote; failing the task beats hanging a daemon
|
|
// slot forever.
|
|
gitTimeout = 2 * time.Minute
|
|
|
|
// maxUntrackedFiles / maxUntrackedBytes bound the untracked-file replay.
|
|
// `--exclude-standard` already drops anything gitignored (node_modules,
|
|
// build output, venvs), so a repo hitting these limits has an unusual
|
|
// amount of untracked-but-not-ignored content. We copy up to the bound and
|
|
// report the remainder rather than silently truncating or hanging on a
|
|
// multi-gigabyte copy.
|
|
maxUntrackedFiles = 2000
|
|
maxUntrackedBytes = 200 << 20 // 200 MiB
|
|
)
|
|
|
|
// gitRootLocks serialises git admin operations per repository. Concurrent
|
|
// `git worktree add` / `remove` / `prune` on one repo race on the same
|
|
// lockfiles (worktrees/, packed-refs.lock, config.lock), and unlike a fetch
|
|
// these are fast, so a plain mutex costs nothing. Keyed by the repo root so
|
|
// tasks on different repos never wait on each other.
|
|
var gitRootLocks sync.Map // gitRoot -> *sync.Mutex
|
|
|
|
func lockGitRoot(gitRoot string) func() {
|
|
v, _ := gitRootLocks.LoadOrStore(gitRoot, &sync.Mutex{})
|
|
mu := v.(*sync.Mutex)
|
|
mu.Lock()
|
|
return mu.Unlock
|
|
}
|
|
|
|
// LocalWorktreeParams describes the worktree Prepare should build for a
|
|
// local_directory task running in worktree mode.
|
|
type LocalWorktreeParams struct {
|
|
// LocalPath is the user's configured directory. It may be the repo root
|
|
// or any subdirectory of it; the worktree always covers the whole repo,
|
|
// and the agent's cwd is the matching subdirectory inside it.
|
|
LocalPath string
|
|
// EnvRoot is the daemon-owned task env root. The worktree is created
|
|
// inside it so the ordinary env-root GC reclaims it.
|
|
EnvRoot string
|
|
// AgentName and TaskID name the branch: agent/<name>/<short-task-id>.
|
|
AgentName string
|
|
TaskID string
|
|
}
|
|
|
|
// LocalWorktree is a prepared worktree plus everything the daemon needs to
|
|
// finalize it after the agent exits.
|
|
type LocalWorktree struct {
|
|
// GitRoot is the user's repository root — the repo that owns the branch.
|
|
GitRoot string
|
|
// Path is the worktree root inside the env root.
|
|
Path string
|
|
// WorkDir is the agent's cwd: Path, plus the offset of LocalPath inside
|
|
// the repo when the user pointed the resource at a subdirectory.
|
|
WorkDir string
|
|
// Branch is the branch created for this task, in the user's repo.
|
|
Branch string
|
|
// BaseCommit is the commit the worktree started from. Finalize compares
|
|
// the branch tip against it to decide whether the task produced anything.
|
|
BaseCommit string
|
|
// DirtyBaseCaptured records that the user had uncommitted tracked edits
|
|
// which were replayed into the worktree.
|
|
DirtyBaseCaptured bool
|
|
// aborted, when set, makes Finalize refuse to commit or remove anything.
|
|
// Set by the daemon when a pre-commit step failed in a way that would make
|
|
// the committed branch wrong (see AbortWithReason).
|
|
aborted error
|
|
// UntrackedCopied / UntrackedSkipped report the untracked-file replay.
|
|
// A non-zero skip count means the bounds below were hit and the agent is
|
|
// looking at less than the user has on disk; it is logged at warn level so
|
|
// the gap is findable rather than invisible.
|
|
UntrackedCopied int
|
|
UntrackedSkipped int
|
|
}
|
|
|
|
// LocalWorktreeOutcome is what a finished worktree task delivered.
|
|
type LocalWorktreeOutcome struct {
|
|
// Branch is the branch holding the task's work, or "" when the task made
|
|
// no changes at all (a read-only run) — in that case the branch is deleted
|
|
// so it never shows up in the user's `git branch` as an empty artifact.
|
|
Branch string
|
|
// AutoCommitted is true when the agent left uncommitted changes that
|
|
// Finalize committed so they would survive the worktree's removal.
|
|
AutoCommitted bool
|
|
// PreservedPath is set only when Finalize could NOT commit the agent's
|
|
// changes. The worktree at this path was intentionally left on disk because
|
|
// it is the only remaining copy of that work.
|
|
PreservedPath string
|
|
}
|
|
|
|
// PrepareLocalWorktree creates the task's worktree and replays the user's
|
|
// uncommitted state into it. It never writes to the user's working tree: the
|
|
// dirty state is read through `git stash create`, which builds a commit object
|
|
// without touching the index or the files on disk.
|
|
func PrepareLocalWorktree(params LocalWorktreeParams, logger *slog.Logger) (*LocalWorktree, error) {
|
|
if params.LocalPath == "" {
|
|
return nil, errors.New("execenv: local worktree requires a local path")
|
|
}
|
|
if params.EnvRoot == "" {
|
|
return nil, errors.New("execenv: local worktree requires an env root")
|
|
}
|
|
if params.TaskID == "" {
|
|
return nil, errors.New("execenv: local worktree requires a task id")
|
|
}
|
|
|
|
gitRoot, err := resolveGitRoot(params.LocalPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// The agent's cwd keeps the user's chosen depth: a resource pointed at
|
|
// <repo>/services/api must land the agent in <worktree>/services/api, not
|
|
// at the repo root, or the task's whole notion of "the project" shifts.
|
|
//
|
|
// Canonicalise before the comparison: gitRoot comes back canonical, while
|
|
// the configured path routinely isn't (on macOS every /tmp and /var path is
|
|
// a symlink into /private). Comparing the two forms directly reads a repo
|
|
// root as "outside itself".
|
|
localPath := params.LocalPath
|
|
if resolved, evalErr := filepath.EvalSymlinks(localPath); evalErr == nil {
|
|
localPath = resolved
|
|
}
|
|
rel, err := filepath.Rel(gitRoot, localPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("execenv: locate %q inside repo %q: %w", localPath, gitRoot, err)
|
|
}
|
|
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
|
return nil, fmt.Errorf("execenv: %q is not inside its repository root %q", localPath, gitRoot)
|
|
}
|
|
|
|
worktreePath := filepath.Join(params.EnvRoot, localWorktreeDirName)
|
|
|
|
// Everything below mutates the repo's worktree admin state, so take the
|
|
// per-repo lock first — including the stale-path cleanup, which runs `git
|
|
// worktree remove` and would otherwise race a sibling task's `worktree add`.
|
|
unlock := lockGitRoot(gitRoot)
|
|
defer unlock()
|
|
|
|
if _, statErr := os.Stat(worktreePath); statErr == nil {
|
|
// Prepare wipes and recreates envRoot, so an existing worktree path
|
|
// means a stale registration in the user's repo pointing here. Remove
|
|
// both rather than failing the task.
|
|
removeLocalWorktreeDir(gitRoot, worktreePath, logger)
|
|
}
|
|
|
|
// Self-heal registrations orphaned by a crashed daemon: their env roots are
|
|
// long gone, but the user's repo still lists them. Prune only drops entries
|
|
// whose directory no longer exists, so it can never disturb a live task.
|
|
if out, pruneErr := runGit(gitRoot, "worktree", "prune"); pruneErr != nil && logger != nil {
|
|
logger.Warn("execenv: git worktree prune failed (non-fatal)",
|
|
"git_root", gitRoot, "output", out, "error", pruneErr)
|
|
}
|
|
|
|
headSHA, err := runGitTrimmed(gitRoot, "rev-parse", "--verify", "HEAD")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("execenv: repository %q has no commit to branch from "+
|
|
"(worktree mode needs at least one commit; make an initial commit or switch the resource back to in_place): %w", gitRoot, err)
|
|
}
|
|
|
|
// `git stash create` builds a commit capturing tracked modifications and
|
|
// returns its sha WITHOUT stashing — the user's index and working tree are
|
|
// untouched. Empty output means the tree is clean. The identity args cover
|
|
// a repo with no user.email configured: writing a commit object needs a
|
|
// committer, and without them the user's uncommitted work would be dropped
|
|
// on a technicality.
|
|
stashSHA, stashErr := runGitTrimmed(gitRoot, append(commitIdentityArgs(gitRoot), "stash", "create")...)
|
|
if stashErr != nil {
|
|
// Fail closed. The promise of this mode is that the agent reasons about
|
|
// the code the user actually has; silently starting from HEAD instead
|
|
// would have it review a tree the user never saw and report confidently
|
|
// on it. A task that does not start is recoverable — one that answers
|
|
// from the wrong sources is not.
|
|
return nil, fmt.Errorf("execenv: could not capture the uncommitted changes in %q, "+
|
|
"so the worktree would not match what you have on disk: %w", gitRoot, stashErr)
|
|
}
|
|
|
|
branch := fmt.Sprintf("agent/%s/%s", sanitizeName(params.AgentName), shortID(params.TaskID))
|
|
actualBranch, err := addLocalWorktree(gitRoot, worktreePath, branch, headSHA)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
wt := &LocalWorktree{
|
|
GitRoot: gitRoot,
|
|
Path: worktreePath,
|
|
WorkDir: filepath.Join(worktreePath, rel),
|
|
Branch: actualBranch,
|
|
BaseCommit: headSHA,
|
|
}
|
|
|
|
// Replay tracked edits. Applied as unstaged modifications on top of HEAD so
|
|
// the branch history stays linear and the agent sees the same
|
|
// work-in-progress the user has open in their editor.
|
|
//
|
|
// Every failure below aborts the prepare and tears the worktree back down.
|
|
// A half-replayed tree is the worst outcome available: it looks like a
|
|
// working checkout, so nothing downstream questions it, while the agent
|
|
// silently reads different code than the user has.
|
|
if stashSHA != "" {
|
|
if out, applyErr := runGit(worktreePath, "stash", "apply", stashSHA); applyErr != nil {
|
|
removeLocalWorktreeDir(gitRoot, worktreePath, logger)
|
|
deleteBranch(gitRoot, actualBranch, logger)
|
|
return nil, fmt.Errorf("execenv: could not replay the uncommitted changes from %q into the task worktree "+
|
|
"(the agent would have seen a different tree than you have): %s: %w",
|
|
gitRoot, strings.TrimSpace(out), applyErr)
|
|
}
|
|
wt.DirtyBaseCaptured = true
|
|
}
|
|
|
|
copied, skipped, err := copyUntrackedFiles(gitRoot, worktreePath, logger)
|
|
if err != nil {
|
|
removeLocalWorktreeDir(gitRoot, worktreePath, logger)
|
|
deleteBranch(gitRoot, actualBranch, logger)
|
|
return nil, fmt.Errorf("execenv: could not replay the untracked files from %q into the task worktree: %w", gitRoot, err)
|
|
}
|
|
if skipped > 0 {
|
|
// Any untracked file we could not reproduce makes the worktree a tree
|
|
// the user would not recognise, so this fails rather than quietly
|
|
// under-copying. Causes: the size/count bounds (usually build output
|
|
// that should have been gitignored), an untracked symlink, or a file
|
|
// that disappeared mid-snapshot. The message names the common fix
|
|
// without claiming to know which one it was.
|
|
removeLocalWorktreeDir(gitRoot, worktreePath, logger)
|
|
deleteBranch(gitRoot, actualBranch, logger)
|
|
return nil, fmt.Errorf("execenv: could not replay every untracked file from %q into the task worktree "+
|
|
"(copied %d, %d left over; the replay covers regular files up to %d files / %d MiB and does not follow symlinks) "+
|
|
"— gitignore or clean up the untracked files, or switch the resource back to in_place",
|
|
gitRoot, copied, skipped, maxUntrackedFiles, maxUntrackedBytes>>20)
|
|
}
|
|
wt.UntrackedCopied = copied
|
|
wt.UntrackedSkipped = skipped
|
|
|
|
// Commit the replayed state as a baseline so "did this task change
|
|
// anything?" has an exact answer later. Without it the user's own
|
|
// uncommitted work counts as a change: a read-only task on a repo with an
|
|
// untracked scratch file would auto-commit that file at the end and leave
|
|
// behind a branch the agent never touched. The baseline also makes the
|
|
// delivered branch readable — `git diff <baseline>..<branch>` is precisely
|
|
// the agent's work, with the user's WIP as its own labelled commit.
|
|
dirty, dirtyErr := worktreeIsDirty(worktreePath)
|
|
if dirtyErr != nil {
|
|
removeLocalWorktreeDir(gitRoot, worktreePath, logger)
|
|
deleteBranch(gitRoot, actualBranch, logger)
|
|
return nil, fmt.Errorf("execenv: could not inspect the prepared worktree for %q: %w", gitRoot, dirtyErr)
|
|
}
|
|
if dirty {
|
|
baseline, baseErr := commitBaseline(worktreePath)
|
|
if baseErr != nil {
|
|
// Without a baseline the task cannot tell the user's work from the
|
|
// agent's, so it would later commit the user's files as if the agent
|
|
// had produced them. Refuse rather than deliver a misleading branch.
|
|
removeLocalWorktreeDir(gitRoot, worktreePath, logger)
|
|
deleteBranch(gitRoot, actualBranch, logger)
|
|
return nil, fmt.Errorf("execenv: could not record a baseline commit for the replayed state of %q: %w", gitRoot, baseErr)
|
|
}
|
|
wt.BaseCommit = baseline
|
|
}
|
|
|
|
// Note on keeping sidecars out of the delivered branch: we deliberately do
|
|
// NOT write .git/info/exclude here. A linked worktree reads info/exclude
|
|
// from the repo's COMMON git dir, so the only file that would take effect
|
|
// is the user's own .git/info/exclude — editing it would change what `git
|
|
// status` shows in the user's checkout, which is theirs, not ours. Instead
|
|
// the daemon runs the existing CleanupRuntimeConfig + CleanupSidecars pass
|
|
// over the worktree before Finalize, so the sidecars are simply gone by the
|
|
// time anything is committed. That also preserves a genuine agent edit to a
|
|
// tracked CLAUDE.md, which a blanket exclude would have swallowed.
|
|
|
|
if logger != nil {
|
|
logger.Info("execenv: local worktree ready",
|
|
"git_root", gitRoot,
|
|
"path", worktreePath,
|
|
"branch", actualBranch,
|
|
"base", headSHA,
|
|
"dirty_base_captured", wt.DirtyBaseCaptured,
|
|
"untracked_copied", copied,
|
|
"untracked_skipped", skipped,
|
|
)
|
|
}
|
|
return wt, nil
|
|
}
|
|
|
|
// Finalize commits whatever the agent left behind, removes the worktree, and
|
|
// reports the branch. Called after the agent exits, before the env root is
|
|
// handed to the GC.
|
|
//
|
|
// The auto-commit is the reason a worktree task can't lose work: `git worktree
|
|
// remove --force` would happily delete uncommitted edits, and the user would
|
|
// have no way to get them back. Committing first turns "the agent edited files"
|
|
// into "the branch has a commit", which is the delivery contract for this mode.
|
|
//
|
|
// If that commit cannot be made — a repo with commit.gpgSign and no signing key
|
|
// available to the daemon, a full disk, a ref lock we lost — Finalize returns an
|
|
// error and DELIBERATELY LEAVES THE WORKTREE IN PLACE. Removing it would be the
|
|
// one operation in this file that destroys work with no way back, and a warning
|
|
// in the daemon log is not an acceptable substitute for the user's changes. The
|
|
// surviving worktree stays registered in the user's repo, so `git worktree list`
|
|
// points straight at it.
|
|
func (w *LocalWorktree) Finalize(logger *slog.Logger) (LocalWorktreeOutcome, error) {
|
|
if w == nil {
|
|
return LocalWorktreeOutcome{}, nil
|
|
}
|
|
unlock := lockGitRoot(w.GitRoot)
|
|
defer unlock()
|
|
|
|
outcome := LocalWorktreeOutcome{Branch: w.Branch}
|
|
|
|
// Something before the commit went wrong in a way that would make the
|
|
// delivered branch misleading. Commit nothing and keep the worktree: the
|
|
// agent's work is still in it, and so is whatever the caller could not
|
|
// clean up, which a human can now look at directly.
|
|
if w.aborted != nil {
|
|
// Report NO branch. One exists in the user's repo, but nothing was
|
|
// committed to it, so naming it as this task's result would point them
|
|
// at a branch that is missing the very work they are looking for. The
|
|
// preserved worktree path below is the honest pointer.
|
|
outcome.Branch = ""
|
|
outcome.PreservedPath = w.Path
|
|
if logger != nil {
|
|
logger.Error("execenv: worktree finalize aborted; nothing committed, worktree kept for inspection",
|
|
"path", w.Path, "branch", w.Branch, "git_root", w.GitRoot, "error", w.aborted)
|
|
}
|
|
return outcome, fmt.Errorf(
|
|
"refusing to deliver branch %s: %w; the task worktree is preserved at %s (listed by `git worktree list` in %s)",
|
|
w.Branch, w.aborted, w.Path, w.GitRoot)
|
|
}
|
|
|
|
// Treat "can't tell" like "dirty": committing costs an empty commit at
|
|
// worst, while assuming clean risks deleting the agent's edits.
|
|
dirty, statusErr := worktreeIsDirty(w.Path)
|
|
if statusErr != nil {
|
|
if logger != nil {
|
|
logger.Warn("execenv: inspect worktree status failed; committing defensively",
|
|
"path", w.Path, "error", statusErr)
|
|
}
|
|
dirty = true
|
|
}
|
|
if dirty {
|
|
committed, err := w.commitAll(logger)
|
|
if err != nil {
|
|
outcome.PreservedPath = w.Path
|
|
if logger != nil {
|
|
logger.Error("execenv: could not commit the agent's changes; keeping the worktree so the work is recoverable",
|
|
"path", w.Path, "branch", w.Branch, "git_root", w.GitRoot, "error", err)
|
|
}
|
|
return outcome, fmt.Errorf(
|
|
"could not commit the agent's changes to branch %s: %w; the work is preserved in the worktree at %s (listed by `git worktree list` in %s) — recover it before that directory is reclaimed",
|
|
w.Branch, err, w.Path, w.GitRoot)
|
|
}
|
|
outcome.AutoCommitted = committed
|
|
}
|
|
|
|
// A branch still sitting exactly on its base commit means the task changed
|
|
// nothing — the read-only case. Delete it so the user's branch list only
|
|
// ever grows for tasks that actually produced work.
|
|
tip, err := runGitTrimmed(w.Path, "rev-parse", "--verify", "HEAD")
|
|
producedWork := err != nil || tip != w.BaseCommit
|
|
|
|
removeLocalWorktreeDir(w.GitRoot, w.Path, logger)
|
|
|
|
if !producedWork {
|
|
deleteBranch(w.GitRoot, w.Branch, logger)
|
|
outcome.Branch = ""
|
|
}
|
|
|
|
if logger != nil {
|
|
logger.Info("execenv: local worktree finalized",
|
|
"git_root", w.GitRoot,
|
|
"branch", outcome.Branch,
|
|
"auto_committed", outcome.AutoCommitted,
|
|
"produced_work", producedWork,
|
|
)
|
|
}
|
|
return outcome, nil
|
|
}
|
|
|
|
// Discard tears a worktree down without delivering anything: unregister it,
|
|
// delete its directory, drop its branch.
|
|
//
|
|
// For the abandon-before-the-agent-ran case only. Finalize is the path that
|
|
// preserves work; this one assumes there is none to preserve, so callers must
|
|
// be sure nothing has run in the worktree yet.
|
|
func (w *LocalWorktree) Discard(logger *slog.Logger) {
|
|
if w == nil {
|
|
return
|
|
}
|
|
unlock := lockGitRoot(w.GitRoot)
|
|
defer unlock()
|
|
removeLocalWorktreeDir(w.GitRoot, w.Path, logger)
|
|
deleteBranch(w.GitRoot, w.Branch, logger)
|
|
if logger != nil {
|
|
logger.Info("execenv: local worktree discarded before the agent ran",
|
|
"git_root", w.GitRoot, "path", w.Path, "branch", w.Branch)
|
|
}
|
|
}
|
|
|
|
// AbortWithReason marks the worktree undeliverable. Finalize will then commit
|
|
// nothing, remove nothing, and return an error naming the preserved path.
|
|
//
|
|
// This exists because the decision "is this branch safe to deliver?" is made
|
|
// outside this package — the daemon knows whether its own sidecar cleanup
|
|
// succeeded — while the only code that can act on it is Finalize. The first
|
|
// reason wins: it is the one closest to the root cause.
|
|
func (w *LocalWorktree) AbortWithReason(err error) {
|
|
if w == nil || err == nil || w.aborted != nil {
|
|
return
|
|
}
|
|
w.aborted = err
|
|
}
|
|
|
|
// commitBaseline records the user's replayed uncommitted state as the first
|
|
// commit on the task branch, returning the new tip.
|
|
func commitBaseline(worktreePath string) (string, error) {
|
|
if _, err := commitEverything(worktreePath, "chore(agent): baseline — uncommitted work from the local directory"); err != nil {
|
|
return "", err
|
|
}
|
|
tip, err := runGitTrimmed(worktreePath, "rev-parse", "--verify", "HEAD")
|
|
if err != nil {
|
|
return "", fmt.Errorf("resolve baseline commit: %w", err)
|
|
}
|
|
return tip, nil
|
|
}
|
|
|
|
// commitAll stages and commits everything the agent left behind. Returns
|
|
// whether a commit was actually created; an error means the changes are still
|
|
// only on disk and the caller must not delete the worktree.
|
|
func (w *LocalWorktree) commitAll(logger *slog.Logger) (bool, error) {
|
|
return commitEverything(w.Path, "chore(agent): uncommitted changes from task")
|
|
}
|
|
|
|
// commitEverything returns (false, nil) for the benign "there was nothing to
|
|
// commit" case and (false, err) for a real failure — the distinction callers
|
|
// need to decide whether the tree is safe to discard.
|
|
func commitEverything(worktreePath, message string) (bool, error) {
|
|
if out, err := runGit(worktreePath, "add", "-A"); err != nil {
|
|
return false, fmt.Errorf("git add: %s: %w", strings.TrimSpace(out), err)
|
|
}
|
|
// --no-verify: the user's commit hooks are written for the user's own
|
|
// workflow (interactive linters, test suites, signing prompts) and a hook
|
|
// failure here would mean losing the agent's work to save a lint run. Note
|
|
// it does NOT disable commit.gpgSign, which is why the caller has to treat
|
|
// a commit failure as "keep the worktree" rather than a warning.
|
|
args := append(commitIdentityArgs(worktreePath), "commit", "--no-verify", "-m", message)
|
|
if out, err := runGit(worktreePath, args...); err != nil {
|
|
if strings.Contains(out, "nothing to commit") {
|
|
return false, nil
|
|
}
|
|
return false, fmt.Errorf("git commit: %s: %w", strings.TrimSpace(out), err)
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// commitIdentityArgs supplies a committer identity only when the repo doesn't
|
|
// already have one. A repo with user.email configured keeps it, so commits
|
|
// still look like they came from the user's own setup.
|
|
func commitIdentityArgs(dir string) []string {
|
|
if email, err := runGitTrimmed(dir, "config", "user.email"); err == nil && email != "" {
|
|
return nil
|
|
}
|
|
return []string{
|
|
"-c", "user.name=Multica Agent",
|
|
"-c", "[email protected]",
|
|
}
|
|
}
|
|
|
|
func worktreeIsDirty(worktreePath string) (bool, error) {
|
|
out, err := runGit(worktreePath, "status", "--porcelain")
|
|
if err != nil {
|
|
return false, fmt.Errorf("git status: %s: %w", strings.TrimSpace(out), err)
|
|
}
|
|
return strings.TrimSpace(out) != "", nil
|
|
}
|
|
|
|
// removeLocalWorktreeDir unregisters the worktree from the user's repo and
|
|
// deletes its directory. The branch is deliberately left alone — it is the
|
|
// task's deliverable.
|
|
func removeLocalWorktreeDir(gitRoot, worktreePath string, logger *slog.Logger) {
|
|
if out, err := runGit(gitRoot, "worktree", "remove", "--force", worktreePath); err != nil {
|
|
if logger != nil {
|
|
logger.Warn("execenv: git worktree remove failed; pruning registration",
|
|
"path", worktreePath, "output", out, "error", err)
|
|
}
|
|
// Fall back to deleting the directory ourselves and dropping the now
|
|
// dangling registration, so the user's repo isn't left listing a
|
|
// worktree that no longer exists.
|
|
if rmErr := os.RemoveAll(worktreePath); rmErr != nil && logger != nil {
|
|
logger.Warn("execenv: remove worktree directory failed", "path", worktreePath, "error", rmErr)
|
|
}
|
|
if out, pruneErr := runGit(gitRoot, "worktree", "prune"); pruneErr != nil && logger != nil {
|
|
logger.Warn("execenv: git worktree prune failed", "output", out, "error", pruneErr)
|
|
}
|
|
}
|
|
}
|
|
|
|
// deleteBranch drops a task branch that carries nothing worth keeping — an
|
|
// empty read-only run, or a prepare that aborted partway. Best-effort: a
|
|
// leftover branch is untidy, never harmful.
|
|
func deleteBranch(gitRoot, branch string, logger *slog.Logger) {
|
|
if branch == "" {
|
|
return
|
|
}
|
|
if out, err := runGit(gitRoot, "branch", "-D", branch); err != nil && logger != nil {
|
|
logger.Warn("execenv: delete task branch failed (non-fatal)",
|
|
"branch", branch, "output", out, "error", err)
|
|
}
|
|
}
|
|
|
|
// resolveGitRoot returns the repository root containing dir. Worktree mode is
|
|
// opt-in per resource, so a non-git directory here is a misconfiguration the
|
|
// user needs to see and fix — we fail closed with an actionable message rather
|
|
// than silently degrading to the in-place lock, which would leave the user
|
|
// wondering why their tasks still queue.
|
|
func resolveGitRoot(dir string) (string, error) {
|
|
root, err := runGitTrimmed(dir, "rev-parse", "--show-toplevel")
|
|
if err != nil || root == "" {
|
|
return "", fmt.Errorf("execenv: local_directory %q is not a git repository, "+
|
|
"but its project resource is set to execution_mode=worktree; "+
|
|
"initialise a repository there or switch the resource back to in_place", dir)
|
|
}
|
|
// EvalSymlinks so the root matches the path git reports from inside the
|
|
// worktree later — on macOS /tmp vs /private/tmp otherwise produce two
|
|
// different lock keys for one repo.
|
|
if resolved, evalErr := filepath.EvalSymlinks(root); evalErr == nil {
|
|
root = resolved
|
|
}
|
|
return filepath.Clean(root), nil
|
|
}
|
|
|
|
// addLocalWorktree creates the worktree, retrying once under a suffixed branch
|
|
// name when the branch already exists (a re-dispatched task keeps its id, so
|
|
// its branch can survive from the previous run).
|
|
func addLocalWorktree(gitRoot, worktreePath, branch, baseRef string) (string, error) {
|
|
out, err := runGit(gitRoot, "worktree", "add", "-b", branch, worktreePath, baseRef)
|
|
if err != nil && strings.Contains(strings.ToLower(out), "already exists") {
|
|
branch = fmt.Sprintf("%s-%d", branch, time.Now().Unix())
|
|
out, err = runGit(gitRoot, "worktree", "add", "-b", branch, worktreePath, baseRef)
|
|
}
|
|
if err != nil {
|
|
return "", fmt.Errorf("execenv: git worktree add: %s: %w", strings.TrimSpace(out), err)
|
|
}
|
|
return branch, nil
|
|
}
|
|
|
|
// copyUntrackedFiles replays the user's untracked-but-not-ignored files into
|
|
// the worktree. `git worktree add` only materialises committed content, so
|
|
// without this a brand-new file the user just created would be invisible to the
|
|
// agent. Bounded by maxUntrackedFiles / maxUntrackedBytes; the number skipped
|
|
// is returned so the caller can tell the user instead of quietly under-copying.
|
|
func copyUntrackedFiles(gitRoot, worktreePath string, logger *slog.Logger) (copied, skipped int, err error) {
|
|
// stdout only: a warning on stderr would otherwise be split apart and
|
|
// treated as file paths to copy. Raw, not trimmed: with -z the entries are
|
|
// exact filenames, and a file whose name begins or ends with whitespace
|
|
// would be trim-corrupted into a path that fails to stat and silently
|
|
// vanishes from the replay.
|
|
out, err := runGitStdout(gitRoot, "ls-files", "--others", "--exclude-standard", "-z")
|
|
if err != nil {
|
|
return 0, 0, fmt.Errorf("git ls-files: %w", err)
|
|
}
|
|
|
|
var budget int64 = maxUntrackedBytes
|
|
for _, rel := range strings.Split(out, "\x00") {
|
|
if rel == "" {
|
|
continue
|
|
}
|
|
// Never replay Multica's own sidecars. They are untracked files in the
|
|
// user's directory whenever an in_place task is mid-flight on the same
|
|
// path, or was killed before its cleanup ran. Copying them would put
|
|
// another issue's brief inside this task's worktree — where the agent
|
|
// would read it as its own context — and commit it to the branch.
|
|
if isMulticaSidecarPath(rel) {
|
|
continue
|
|
}
|
|
if copied >= maxUntrackedFiles || budget <= 0 {
|
|
skipped++
|
|
continue
|
|
}
|
|
src := filepath.Join(gitRoot, rel)
|
|
info, statErr := os.Lstat(src)
|
|
if statErr != nil {
|
|
// Listed a moment ago, unreadable now — the tree changed under us,
|
|
// so the snapshot no longer matches what the user has. Counted, not
|
|
// skipped silently: the caller fails the task on a non-zero count.
|
|
skipped++
|
|
if logger != nil {
|
|
logger.Warn("execenv: untracked file vanished between listing and copy",
|
|
"file", rel, "error", statErr)
|
|
}
|
|
continue
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
// An untracked symlink is content the user can see. Reproducing it
|
|
// faithfully means deciding whether to copy the link or its target
|
|
// — including targets outside the repo — so this replay does not
|
|
// try. Count it so the task fails rather than handing the agent a
|
|
// tree with a file quietly missing.
|
|
skipped++
|
|
if logger != nil {
|
|
logger.Warn("execenv: untracked symlink not replayed into worktree", "file", rel)
|
|
}
|
|
continue
|
|
}
|
|
if !info.Mode().IsRegular() {
|
|
// Sockets, FIFOs, devices: not content, and not something an agent
|
|
// can meaningfully read from a copy. Skipping them does not make the
|
|
// snapshot misleading, so this one stays uncounted.
|
|
continue
|
|
}
|
|
if info.Size() > budget {
|
|
skipped++
|
|
continue
|
|
}
|
|
if copyErr := copyUntrackedFile(src, filepath.Join(worktreePath, rel), info.Mode()); copyErr != nil {
|
|
skipped++
|
|
if logger != nil {
|
|
logger.Warn("execenv: copy untracked file into worktree failed", "file", rel, "error", copyErr)
|
|
}
|
|
continue
|
|
}
|
|
budget -= info.Size()
|
|
copied++
|
|
}
|
|
return copied, skipped, nil
|
|
}
|
|
|
|
// copyUntrackedFile copies one untracked file into the worktree, creating
|
|
// parent directories and preserving the executable bit — a script the user just
|
|
// wrote and hasn't committed has to stay runnable for the agent.
|
|
// multicaSidecarDirNames are the directories Prepare writes into a workdir. A
|
|
// task running in_place on the same directory leaves these present as
|
|
// untracked files for the length of its run, so a concurrent worktree snapshot
|
|
// sees them. CLAUDE.md / AGENTS.md are deliberately absent: those are
|
|
// ordinarily the user's own tracked files, and the runtime only injects a
|
|
// marker block into them, which CleanupRuntimeConfig removes.
|
|
var multicaSidecarDirNames = []string{
|
|
".agent_context",
|
|
".multica",
|
|
}
|
|
|
|
// isMulticaSidecarPath reports whether a repo-relative path is one of the
|
|
// daemon's own sidecars rather than the user's content. Matched as a whole
|
|
// path segment at ANY depth, not just the repo root: an in_place resource may
|
|
// point at a subdirectory of this repo, in which case its sidecars sit at
|
|
// <subdir>/.agent_context — replaying those would put another issue's brief
|
|
// inside this task's worktree and commit it to the delivered branch.
|
|
func isMulticaSidecarPath(rel string) bool {
|
|
for _, seg := range strings.Split(filepath.ToSlash(rel), "/") {
|
|
for _, name := range multicaSidecarDirNames {
|
|
if seg == name {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func copyUntrackedFile(src, dst string, mode os.FileMode) error {
|
|
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
|
return err
|
|
}
|
|
if err := copyFile(src, dst); err != nil {
|
|
return err
|
|
}
|
|
return os.Chmod(dst, mode.Perm())
|
|
}
|
|
|
|
// runGit runs git in dir and returns combined output. Callers inspect the
|
|
// output for git's own error text, so stdout and stderr stay merged.
|
|
func runGit(dir string, args ...string) (string, error) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), gitTimeout)
|
|
defer cancel()
|
|
|
|
full := append([]string{"-C", dir}, args...)
|
|
cmd := exec.CommandContext(ctx, "git", full...)
|
|
cmd.WaitDelay = 5 * time.Second
|
|
out, err := cmd.CombinedOutput()
|
|
return string(out), err
|
|
}
|
|
|
|
// runGitTrimmed runs git for its stdout value, discarding stderr so a
|
|
// diagnostic line can't be mistaken for the value (`rev-parse` output, a
|
|
// config value, a stash sha).
|
|
func runGitTrimmed(dir string, args ...string) (string, error) {
|
|
out, err := runGitStdout(dir, args...)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return strings.TrimSpace(out), nil
|
|
}
|
|
|
|
// runGitStdout is runGitTrimmed without the trimming, for output where
|
|
// whitespace is significant — NUL-separated file listings, where a leading or
|
|
// trailing space is part of a filename.
|
|
func runGitStdout(dir string, args ...string) (string, error) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), gitTimeout)
|
|
defer cancel()
|
|
|
|
full := append([]string{"-C", dir}, args...)
|
|
cmd := exec.CommandContext(ctx, "git", full...)
|
|
cmd.WaitDelay = 5 * time.Second
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(out), nil
|
|
}
|