MUL-7284: fix(daemon): keep an existing checkout's work when repo checkout runs again (#8309)

* fix(daemon): keep an existing checkout's work when repo checkout runs again

Re-running `multica repo checkout` on a path that already held a checkout
ran git reset --hard and git clean -fd and moved it to a new branch from the
default branch; isolated checkouts also deleted earlier agent/* branches.
Uncommitted changes, untracked files, and unpushed commits were lost with
output that still read as a normal checkout.

An existing checkout that holds work, or is already on this task's branch,
is now kept exactly as it is and only its remote refs are fetched. The
result reports that it was kept, with its branch and uncommitted/unpushed
counts, and the CLI says so. --fresh restores the old start-over behavior.
Isolated-mode pruning keeps agent/* branches with unpushed commits, and a
linked worktree holding work is no longer removed to migrate it.

MUL-7284

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): carry a migrated linked worktree's unpushed branch into the isolated checkout

--fresh migration of a linked worktree to an isolated checkout removed the
worktree and seeded a new clone whose copied branches are all deleted, so a
branch holding unpushed commits stayed only in the shared cache: out of the
agent's reach and dropped by the next GC. Carry that one branch into the new
checkout (no other cache branch), imported before the task branch so a
same-named task branch moves to a timestamped name.

Align the agent-facing --fresh wording: it discards uncommitted and untracked
files and starts a new branch; commits stay on the old branch, but push any
still needed first, since a linked worktree's old branch lives in the shared
cache that GC prunes.

MUL-7284

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
Bohan Jiang
2026-09-11 17:02:32 +08:00
committed by GitHub
co-authored by multica-agent J
parent da7fc439a8
commit 2441638c05
11 changed files with 923 additions and 115 deletions
+48 -9
View File
@@ -50,12 +50,19 @@ var repoRemoveCmd = &cobra.Command{
var repoCheckoutCmd = &cobra.Command{
Use: "checkout <url>",
Short: "Check out a repository into the working directory",
Long: "Creates a git worktree from the daemon's bare clone cache. Used by agents to check out repos on demand.",
Args: exactArgs(1),
RunE: runRepoCheckout,
Long: "Creates a git worktree from the daemon's bare clone cache. Used by agents to check out repos on demand.\n\n" +
"Running it again where the repository is already checked out never silently discards work: a checkout " +
"that has uncommitted changes, untracked files, or unpushed commits, or is already on this task's branch, " +
"is kept as it is and only its remote refs are fetched. Pass --fresh to discard its uncommitted changes and " +
"untracked files and start over on a new branch; commits stay on the old branch, but push any you still need first.",
Args: exactArgs(1),
RunE: runRepoCheckout,
}
var repoCheckoutRef string
var (
repoCheckoutRef string
repoCheckoutFresh bool
)
func init() {
repoListCmd.Flags().String("output", "table", "Output format: table or json")
@@ -68,6 +75,7 @@ func init() {
repoRemoveCmd.Flags().String("output", "json", "Output format: table or json")
repoCheckoutCmd.Flags().StringVar(&repoCheckoutRef, "ref", "", "branch, tag, or commit to check out instead of the remote default branch")
repoCheckoutCmd.Flags().BoolVar(&repoCheckoutFresh, "fresh", false, "discard an existing checkout's uncommitted changes and untracked files and start over on a new branch from the latest default branch (or --ref); commits stay on the old branch")
repoCmd.AddCommand(repoListCmd)
repoCmd.AddCommand(repoAddCmd)
@@ -363,6 +371,7 @@ func runRepoCheckout(cmd *cobra.Command, args []string) error {
"task_id": taskID,
"checkout_mode": strings.TrimSpace(os.Getenv("MULTICA_REPO_CHECKOUT_MODE")),
"retry_busy": true,
"fresh": repoCheckoutFresh,
}
data, err := json.Marshal(reqBody)
@@ -415,20 +424,50 @@ func runRepoCheckout(cmd *cobra.Command, args []string) error {
break
}
var result struct {
Path string `json:"path"`
BranchName string `json:"branch_name"`
}
var result repoCheckoutResult
if err := json.Unmarshal(body, &result); err != nil {
return fmt.Errorf("parse response: %w", err)
}
fmt.Fprintf(os.Stdout, "%s\n", result.Path)
fmt.Fprintf(os.Stderr, "Checked out %s → %s (branch: %s)\n", repoURL, result.Path, result.BranchName)
fmt.Fprintln(os.Stderr, repoCheckoutSummary(repoURL, result))
return nil
}
// repoCheckoutResult is the daemon's /repo/checkout response. Daemons older
// than MUL-7284 never keep an existing checkout and omit Kept and the counts.
type repoCheckoutResult struct {
Path string `json:"path"`
BranchName string `json:"branch_name"`
Kept string `json:"kept"`
UncommittedFiles int `json:"uncommitted_files"`
UnpushedCommits int `json:"unpushed_commits"`
}
// repoCheckoutSummary says what the checkout did. A kept checkout has to read
// differently from a new branch off the default branch, or the agent works on
// as if the checkout were fresh and loses track of what it holds.
func repoCheckoutSummary(repoURL string, result repoCheckoutResult) string {
if result.Kept == "" {
return fmt.Sprintf("Checked out %s → %s (branch: %s)", repoURL, result.Path, result.BranchName)
}
branch := result.BranchName
if branch == "" {
branch = "detached HEAD"
}
if result.Kept == "task_branch" {
branch += ", this task's branch"
}
return fmt.Sprintf("Kept the existing checkout of %s at %s (branch: %s; %d uncommitted file%s, %d unpushed commit%s): "+
"nothing was reset, cleaned, or switched; only remote refs were fetched.\n"+
"To discard its uncommitted changes and untracked files and start over on a new branch from the latest default branch (or --ref), "+
"re-run with --fresh; commits stay on the old branch, but push any you still need first.",
repoURL, result.Path, branch,
result.UncommittedFiles, pluralS(result.UncommittedFiles),
result.UnpushedCommits, pluralS(result.UnpushedCommits))
}
func repoCheckoutRetryDelay(value string, now time.Time) time.Duration {
const (
defaultDelay = time.Second
+53 -3
View File
@@ -217,9 +217,9 @@ func TestRunRepoCheckoutForwardsManagedCheckoutMode(t *testing.T) {
t.Setenv("MULTICA_TOKEN", "mat_repo_checkout_test")
t.Setenv("MULTICA_REPO_CHECKOUT_MODE", "isolated")
previousRef := repoCheckoutRef
repoCheckoutRef = "release/v2"
defer func() { repoCheckoutRef = previousRef }()
previousRef, previousFresh := repoCheckoutRef, repoCheckoutFresh
repoCheckoutRef, repoCheckoutFresh = "release/v2", true
defer func() { repoCheckoutRef, repoCheckoutFresh = previousRef, previousFresh }()
if err := runRepoCheckout(&cobra.Command{}, []string{"https://github.com/org/repo.git"}); err != nil {
t.Fatalf("runRepoCheckout: %v", err)
@@ -233,6 +233,56 @@ func TestRunRepoCheckoutForwardsManagedCheckoutMode(t *testing.T) {
if got := body["retry_busy"]; got != true {
t.Fatalf("retry_busy = %v, want true", got)
}
if got := body["fresh"]; got != true {
t.Fatalf("fresh = %v, want true", got)
}
}
func TestRepoCheckoutSummary(t *testing.T) {
t.Parallel()
const repoURL = "https://github.com/org/repo.git"
for _, tc := range []struct {
name string
result repoCheckoutResult
want []string
}{
{
name: "new branch",
result: repoCheckoutResult{Path: "/work/repo", BranchName: "agent/test/task"},
want: []string{"Checked out " + repoURL + " → /work/repo (branch: agent/test/task)"},
},
{
name: "kept for local work",
result: repoCheckoutResult{Path: "/work/repo", BranchName: "agent/test/old", Kept: "local_work", UncommittedFiles: 2, UnpushedCommits: 1},
want: []string{
"Kept the existing checkout of " + repoURL + " at /work/repo (branch: agent/test/old; 2 uncommitted files, 1 unpushed commit)",
"nothing was reset, cleaned, or switched",
"re-run with --fresh",
},
},
{
name: "kept on the task branch",
result: repoCheckoutResult{Path: "/work/repo", BranchName: "agent/test/task", Kept: "task_branch"},
want: []string{"(branch: agent/test/task, this task's branch; 0 uncommitted files, 0 unpushed commits)"},
},
{
name: "kept on a detached HEAD",
result: repoCheckoutResult{Path: "/work/repo", Kept: "local_work", UnpushedCommits: 3},
want: []string{"(branch: detached HEAD; 0 uncommitted files, 3 unpushed commits)"},
},
} {
t.Run(tc.name, func(t *testing.T) {
got := repoCheckoutSummary(repoURL, tc.result)
for _, want := range tc.want {
if !strings.Contains(got, want) {
t.Fatalf("summary missing %q:\n%s", want, got)
}
}
if kept := strings.HasPrefix(got, "Kept "); kept != (tc.result.Kept != "") {
t.Fatalf("summary reads kept=%v for Kept=%q:\n%s", kept, tc.result.Kept, got)
}
})
}
}
func TestRunRepoCheckoutRequiresTaskCredential(t *testing.T) {
@@ -275,7 +275,7 @@ func writeAvailableCommands(b *strings.Builder, ctx TaskContextForEnv) {
writeIssueStatusCommand(b, ctx)
b.WriteString("- `multica issue children <id> [--output json]` — list a parent's sub-issues grouped by stage.\n")
b.WriteString("- `multica issue comment add <issue-id> [--content \"...\" | --content-file <path> | --content-stdin] [--parent <comment-id>] [--attachment <path>]` — post a comment. Agent-authored bodies MUST use `--content-file`; see `## Comment Formatting` for why. `multica issue comment add --help` for full flags.\n")
b.WriteString("- `multica repo checkout <url> [--ref <branch-or-sha>]` — repository checkout on a dedicated branch.\n\n")
b.WriteString("- `multica repo checkout <url> [--ref <branch-or-sha>] [--fresh]` — repository checkout on a dedicated branch. Re-running it keeps an existing checkout that has uncommitted or unpushed work, or is already on this task's branch, and only fetches. `--fresh` discards uncommitted and untracked files and starts a new branch; commits stay on the old branch, but push any you still need first.\n\n")
// Squad maintenance is squad-leader surface: an agent that leads no squad
// has no squad to change roles in, so this shipped to every run as dead
// weight (MUL-5442). IsSquadLeader is a PER-TASK role (the daemon derives
+6
View File
@@ -106,6 +106,11 @@ type repoCheckoutRequest struct {
// RetryBusy is sent by clients that understand 503 + Retry-After. Older
// clients omit it and retain their historical unbounded lock-wait behavior.
RetryBusy bool `json:"retry_busy,omitempty"`
// Fresh asks to discard an existing checkout's uncommitted changes and
// untracked files and start over on a new branch (`multica repo checkout
// --fresh`). Without it an existing checkout that holds work is kept; older
// daemons ignore the field and always start over.
Fresh bool `json:"fresh,omitempty"`
}
type activeRepoCheckoutTask struct {
@@ -481,6 +486,7 @@ func (d *Daemon) repoCheckoutHandler() http.HandlerFunc {
TaskID: req.TaskID,
CoAuthoredByEnabled: d.workspaceCoAuthoredByEnabled(req.WorkspaceID),
IsolatedGitMetadata: req.CheckoutMode == repoCheckoutModeIsolated,
Fresh: req.Fresh,
}
if req.RetryBusy {
params.LockWaitTimeout = repoCheckoutLockWaitTimeout
+27
View File
@@ -546,6 +546,33 @@ func TestRepoCheckoutForwardsIsolatedMode(t *testing.T) {
}
}
func TestRepoCheckoutForwardsFresh(t *testing.T) {
t.Parallel()
const workspaceID = "ws-checkout"
const repoURL = "https://github.com/org/repo.git"
cache := &recordingRepoCache{lookupPath: "/cache/org/repo.git"}
workDir := t.TempDir()
d := newRepoCheckoutTestDaemon(t, workspaceID, repoURL, workDir, cache)
for _, tc := range []struct {
body string
want bool
}{
{body: `{"url":"` + repoURL + `","workspace_id":"` + workspaceID + `","workdir":"` + workDir + `","task_id":"task-1"}`, want: false},
{body: `{"url":"` + repoURL + `","workspace_id":"` + workspaceID + `","workdir":"` + workDir + `","task_id":"task-1","fresh":true}`, want: true},
} {
rec := httptest.NewRecorder()
d.repoCheckoutHandler().ServeHTTP(rec, authorizedRepoCheckoutRequest(strings.NewReader(tc.body)))
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
if got := cache.lastCreateParams().Fresh; got != tc.want {
t.Fatalf("CreateWorktree Fresh = %v, want %v for %s", got, tc.want, tc.body)
}
}
}
func TestRepoCheckoutRejectsUnknownMode(t *testing.T) {
t.Parallel()
+7 -8
View File
@@ -131,13 +131,12 @@ func buildSharedLocalDirectoryBlock(shared bool) string {
}
// buildReusedWorkdirBlock tells a fresh session that its working directory
// already holds an earlier run's files. Nothing else says so: the brief points
// at `multica repo checkout` for fetching code, and on an existing checkout
// that command runs `git reset --hard` and `git clean -fd` before branching
// from the default branch, so following the brief would delete the
// uncommitted work the reuse exists to keep. Per-turn, not in the brief: it is
// true of this run only, and the brief must stay byte-stable across runs
// (MUL-5377).
// already holds an earlier run's files. Nothing else says so, and the brief
// points at `multica repo checkout` for fetching code. That command keeps an
// existing checkout holding work (MUL-7284), but a session that does not know
// the work is there neither continues from it nor knows to leave `--fresh`
// alone. Per-turn, not in the brief: it is true of this run only, and the brief
// must stay byte-stable across runs (MUL-5377).
func buildReusedWorkdirBlock(reused bool) string {
if !reused {
return ""
@@ -145,7 +144,7 @@ func buildReusedWorkdirBlock(reused bool) string {
var b strings.Builder
b.WriteString("## Files from an earlier run\n\n")
b.WriteString("Your working directory was kept from an earlier run, but this run does not continue that run's conversation, so you have no memory of what it did here. Repository checkouts in it may hold that work, including uncommitted changes.\n\n")
b.WriteString("Look before you fetch anything: list the working directory, and in each existing checkout run `git status` and `git log` to see what was changed. Continue from that work where it serves this task. Do not run `multica repo checkout` for a repository that is already checked out here unless you have confirmed nothing in it needs keeping — on an existing checkout it discards uncommitted changes, untracked files included, and starts a new branch from the default branch.\n\n")
b.WriteString("Look before you fetch anything: list the working directory, and in each existing checkout run `git status` and `git log` to see what was changed. Continue from that work where it serves this task. Running `multica repo checkout` for a repository that is already checked out here keeps that checkout as it is when it holds uncommitted changes, untracked files, or unpushed commits, and only fetches. Pass `--fresh` only once you have confirmed nothing in it needs keeping: it discards uncommitted changes and untracked files and starts a new branch from the default branch, leaving commits on the old branch — push any you still need first.\n\n")
return b.String()
}
+7 -5
View File
@@ -2073,9 +2073,10 @@ func TestWorktreeReplayConflictBlock(t *testing.T) {
}
// TestReusedWorkdirBlock covers the warning a fresh session gets when it lands
// in an earlier run's workdir (MUL-7034): it must say the files are there and
// that `multica repo checkout` would reset an existing checkout, and it is
// appended per turn after the prompt body, never ahead of it. That it reaches
// in an earlier run's workdir (MUL-7034): it must say the files are there, that
// `multica repo checkout` keeps an existing checkout holding work, and that
// `--fresh` discards it (MUL-7284). It is appended per turn after the prompt
// body, never ahead of it. That it reaches
// the backend's prompt and not the brief is pinned end to end by
// TestRunTaskWarnsFreshSessionInReusedWorkdir.
func TestReusedWorkdirBlock(t *testing.T) {
@@ -2101,8 +2102,9 @@ func TestReusedWorkdirBlock(t *testing.T) {
"## Files from an earlier run",
"no memory of what it did here",
"`git status`",
"Do not run `multica repo checkout` for a repository that is already checked out here",
"discards uncommitted changes",
"Running `multica repo checkout` for a repository that is already checked out here keeps that checkout as it is",
"Pass `--fresh` only once you have confirmed nothing in it needs keeping",
"leaving commits on the old branch",
} {
if !strings.Contains(block, want) {
t.Fatalf("warning missing %q:\n%s", want, block)
+249 -54
View File
@@ -736,18 +736,44 @@ type WorktreeParams struct {
// listed as a writable root — on Linux (multica-ai/multica#2925) and on the
// Windows native sandbox (multica-ai/multica#6449).
IsolatedGitMetadata bool
// Fresh discards what an existing checkout at the target path holds —
// uncommitted changes, untracked files, its branch position — and starts
// over on a new branch from the base ref. It deletes no branch holding
// unpushed commits. Without it, an existing checkout that holds work or is
// already on this task's branch is kept as it is.
Fresh bool
}
// WorktreeResult describes a successfully created worktree.
type WorktreeResult struct {
Path string `json:"path"` // absolute path to the worktree
BranchName string `json:"branch_name"` // git branch created for this worktree
BranchName string `json:"branch_name"` // branch checked out; empty for a kept checkout on a detached HEAD
// Kept is set when an existing checkout was left exactly as it was — not
// reset, cleaned, switched, or pruned — and only its remote refs were
// fetched. It names why: KeptTaskBranch or KeptLocalWork.
Kept string `json:"kept,omitempty"`
// UncommittedFiles and UnpushedCommits describe a kept checkout: the paths
// `git status` reports, untracked files included, and the commits on HEAD
// that no remote-tracking ref reaches.
UncommittedFiles int `json:"uncommitted_files,omitempty"`
UnpushedCommits int `json:"unpushed_commits,omitempty"`
}
// Reasons CreateWorktree keeps an existing checkout, reported in
// WorktreeResult.Kept.
const (
// KeptTaskBranch: the checkout is already on this task's branch, so the
// checkout was already done.
KeptTaskBranch = "task_branch"
// KeptLocalWork: the checkout holds uncommitted changes, untracked files,
// or unpushed commits that moving it to a new branch would lose.
KeptLocalWork = "local_work"
)
// CreateWorktree looks up the bare cache for a repo, fetches latest, and creates
// a git worktree in the agent's working directory. If a worktree already exists
// at the target path (reused environment), it updates the existing worktree to
// the latest remote default branch instead of failing.
// a git worktree in the agent's working directory. If a checkout already exists
// at the target path (reused environment), updateExistingCheckoutContext
// decides what happens to it.
func (c *Cache) CreateWorktree(params WorktreeParams) (*WorktreeResult, error) {
return c.CreateWorktreeContext(context.Background(), params)
}
@@ -847,13 +873,14 @@ func (c *Cache) CreateWorktreeContext(ctx context.Context, params WorktreeParams
// that omits the mode hint. This also makes provider transitions on a reused
// workdir backward compatible.
if params.IsolatedGitMetadata || isIsolatedCheckoutContext(ctx, worktreePath) {
actualBranch, err := c.createOrUpdateIsolatedCheckoutContext(
result, err := c.createOrUpdateIsolatedCheckoutContext(
ctx,
barePath,
params.RepoURL,
worktreePath,
branchName,
baseRef,
params.Fresh,
)
if err != nil {
return nil, fmt.Errorf("create isolated checkout: %w", err)
@@ -867,19 +894,14 @@ func (c *Cache) CreateWorktreeContext(ctx context.Context, params WorktreeParams
return nil, context.Cause(ctx)
}
c.logger.Info("repo checkout: isolated checkout ready",
"url", params.RepoURL,
"path", worktreePath,
"branch", actualBranch,
"base", baseRef,
)
return &WorktreeResult{Path: worktreePath, BranchName: actualBranch}, nil
c.logCheckoutReady("repo checkout: isolated checkout ready", params.RepoURL, baseRef, result)
return result, nil
}
// If worktree already exists (reused environment from a prior task),
// update it to the latest remote code instead of creating a new one.
// reuse it instead of creating a new one.
if isGitWorktree(worktreePath) {
actualBranch, err := updateExistingWorktreeContext(ctx, worktreePath, branchName, baseRef)
result, err := updateExistingCheckoutContext(ctx, worktreePath, branchName, baseRef, params.Fresh)
if err != nil {
return nil, fmt.Errorf("update existing worktree: %w", err)
}
@@ -898,17 +920,8 @@ func (c *Cache) CreateWorktreeContext(ctx context.Context, params WorktreeParams
return nil, context.Cause(ctx)
}
c.logger.Info("repo checkout: existing worktree updated",
"url", params.RepoURL,
"path", worktreePath,
"branch", actualBranch,
"base", baseRef,
)
return &WorktreeResult{
Path: worktreePath,
BranchName: actualBranch,
}, nil
c.logCheckoutReady("repo checkout: existing worktree updated", params.RepoURL, baseRef, result)
return result, nil
}
// Create a new worktree. createWorktree may rename the branch to avoid
@@ -944,6 +957,29 @@ func (c *Cache) CreateWorktreeContext(ctx context.Context, params WorktreeParams
}, nil
}
// logCheckoutReady logs how CreateWorktree left an existing or isolated
// checkout, naming a kept one as such so the log never reads as if its work
// had moved to baseRef.
func (c *Cache) logCheckoutReady(msg, repoURL, baseRef string, result *WorktreeResult) {
if result.Kept != "" {
c.logger.Info("repo checkout: existing checkout kept",
"url", repoURL,
"path", result.Path,
"branch", result.BranchName,
"reason", result.Kept,
"uncommitted_files", result.UncommittedFiles,
"unpushed_commits", result.UnpushedCommits,
)
return
}
c.logger.Info(msg,
"url", repoURL,
"path", result.Path,
"branch", result.BranchName,
"base", baseRef,
)
}
const (
isolatedCheckoutConfigKey = "multica.checkout-mode"
isolatedCheckoutConfigValue = "isolated"
@@ -957,58 +993,82 @@ const (
// The temporary cache remote is then replaced with the real repository URL so
// an agent's normal fetch / push commands still target GitHub rather than the
// daemon-owned bare cache.
func (c *Cache) createOrUpdateIsolatedCheckout(barePath, repoURL, checkoutPath, branchName, baseRef string) (string, error) {
return c.createOrUpdateIsolatedCheckoutContext(context.Background(), barePath, repoURL, checkoutPath, branchName, baseRef)
func (c *Cache) createOrUpdateIsolatedCheckout(barePath, repoURL, checkoutPath, branchName, baseRef string, fresh bool) (*WorktreeResult, error) {
return c.createOrUpdateIsolatedCheckoutContext(context.Background(), barePath, repoURL, checkoutPath, branchName, baseRef, fresh)
}
func (c *Cache) createOrUpdateIsolatedCheckoutContext(ctx context.Context, barePath, repoURL, checkoutPath, branchName, baseRef string) (string, error) {
func (c *Cache) createOrUpdateIsolatedCheckoutContext(ctx context.Context, barePath, repoURL, checkoutPath, branchName, baseRef string, fresh bool) (*WorktreeResult, error) {
baseCommit, err := resolveCommitContext(ctx, barePath, baseRef)
if err != nil {
return "", err
return nil, err
}
if isIsolatedCheckoutContext(ctx, checkoutPath) {
if err := setIsolatedCheckoutOriginContext(ctx, checkoutPath, repoURL); err != nil {
return "", err
return nil, err
}
// Idempotent, and required for a workdir that was first created while
// the cache was still a full clone: without it, a checkout backed by a
// blobless cache resolves missing blobs to nothing instead of fetching.
if isPartialCloneContext(ctx, barePath) {
if err := configurePromisorRemoteContext(ctx, checkoutPath); err != nil {
return "", err
return nil, err
}
}
// Refresh the remote refs before inspecting the checkout: a kept
// checkout still gets them, and they decide which commits are unpushed.
if err := syncIsolatedCheckoutRefsContext(ctx, barePath, checkoutPath, baseRef); err != nil {
return "", err
return nil, err
}
actualBranch, err := updateExistingWorktreeContext(ctx, checkoutPath, branchName, baseCommit)
if err != nil {
return "", err
result, err := updateExistingCheckoutContext(ctx, checkoutPath, branchName, baseCommit, fresh)
if err != nil || result.Kept != "" {
return result, err
}
// Drop earlier tasks' agent/* heads so a reused workdir doesn't grow a
// new local branch on every checkout. Non-fatal: leftover branches are
// harmless clutter and must never fail the checkout.
if err := deleteStaleAgentBranchesContext(ctx, checkoutPath, actualBranch); err != nil {
if err := deleteStaleAgentBranchesContext(ctx, checkoutPath, result.BranchName); err != nil {
c.logger.Warn("repo checkout: prune stale branches failed (non-fatal)", "error", err)
}
return actualBranch, nil
return result, nil
}
// A daemon upgrade can resume a pre-fix Codex workdir that still has a
// linked worktree. Remove it through Git (so the shared admin record is
// cleaned too), then recreate the same checkout path with local metadata.
// Removing it deletes its working tree, so one keepReason claims stays a
// linked worktree until the caller asks for fresh. Even then its branch
// comes along when it holds unpushed commits: fresh discards the working
// tree, not commits, and left in the shared cache the branch would be
// out of the agent's reach and dropped by the next GC.
var carryBranch string
if isGitWorktree(checkoutPath) {
state, err := inspectCheckoutContext(ctx, checkoutPath)
if err != nil {
return nil, err
}
if !fresh {
if state.Kept = keepReason(state, branchName); state.Kept != "" {
return state, nil
}
}
if state.BranchName != "" && state.UnpushedCommits > 0 {
carryBranch = state.BranchName
}
if err := removeLinkedWorktreeContext(ctx, barePath, checkoutPath); err != nil {
return "", err
return nil, err
}
}
if _, err := os.Stat(checkoutPath); err == nil {
return "", fmt.Errorf("checkout path already exists and is not a Multica isolated checkout: %s", checkoutPath)
return nil, fmt.Errorf("checkout path already exists and is not a Multica isolated checkout: %s", checkoutPath)
} else if !os.IsNotExist(err) {
return "", fmt.Errorf("stat checkout path: %w", err)
return nil, fmt.Errorf("stat checkout path: %w", err)
}
return createIsolatedCheckoutContext(ctx, barePath, repoURL, checkoutPath, branchName, baseRef, baseCommit)
actualBranch, err := createIsolatedCheckoutContext(ctx, barePath, repoURL, checkoutPath, branchName, baseRef, baseCommit, carryBranch)
if err != nil {
return nil, err
}
return &WorktreeResult{Path: checkoutPath, BranchName: actualBranch}, nil
}
func removeLinkedWorktree(barePath, checkoutPath string) error {
@@ -1070,11 +1130,14 @@ func localCloneArgs(goos, barePath, checkoutPath string) []string {
return append(args, "--origin", isolatedCacheRemoteName, barePath, checkoutPath)
}
func createIsolatedCheckout(barePath, repoURL, checkoutPath, branchName, baseRef, baseCommit string) (_ string, retErr error) {
return createIsolatedCheckoutContext(context.Background(), barePath, repoURL, checkoutPath, branchName, baseRef, baseCommit)
// createIsolatedCheckout seeds a new isolated checkout from the cache. A
// non-empty carryBranch names a cache branch to import as a local branch of
// the same name — the branch of the linked worktree this checkout replaces.
func createIsolatedCheckout(barePath, repoURL, checkoutPath, branchName, baseRef, baseCommit, carryBranch string) (_ string, retErr error) {
return createIsolatedCheckoutContext(context.Background(), barePath, repoURL, checkoutPath, branchName, baseRef, baseCommit, carryBranch)
}
func createIsolatedCheckoutContext(ctx context.Context, barePath, repoURL, checkoutPath, branchName, baseRef, baseCommit string) (_ string, retErr error) {
func createIsolatedCheckoutContext(ctx context.Context, barePath, repoURL, checkoutPath, branchName, baseRef, baseCommit, carryBranch string) (_ string, retErr error) {
if out, err := runGitCombinedOutputContext(
ctx,
localCloneArgs(runtime.GOOS, barePath, checkoutPath)...,
@@ -1124,6 +1187,15 @@ func createIsolatedCheckoutContext(ctx context.Context, barePath, repoURL, check
if err := deleteAllLocalBranchesContext(ctx, checkoutPath); err != nil {
return "", err
}
// Import the carried branch before creating the task branch, so a carried
// branch with the task branch's own name moves the new one to a
// timestamped name instead of being overwritten.
if carryBranch != "" {
ref := "refs/heads/" + carryBranch
if out, err := runGitCombinedOutputContext(ctx, "-C", checkoutPath, "fetch", "--no-tags", barePath, ref+":"+ref); err != nil {
return "", fmt.Errorf("carry branch %s: %s: %w", carryBranch, strings.TrimSpace(string(out)), err)
}
}
if out, err := runGitCombinedOutputContext(ctx, "-C", checkoutPath, "config", isolatedCheckoutConfigKey, isolatedCheckoutConfigValue); err != nil {
return "", fmt.Errorf("mark isolated checkout: %s: %w", strings.TrimSpace(string(out)), err)
}
@@ -1250,33 +1322,54 @@ func deleteAllLocalBranches(repoPath string) error {
}
func deleteAllLocalBranchesContext(ctx context.Context, repoPath string) error {
return deleteLocalBranchesUnderContext(ctx, repoPath, "refs/heads/", "")
return deleteLocalBranchesUnderContext(ctx, repoPath, "refs/heads/", nil)
}
// deleteStaleAgentBranches prunes branches left by earlier Multica tasks while
// preserving the current task branch and every user-created local branch.
// preserving the current task branch, every user-created local branch, and
// every agent branch holding commits no remote-tracking ref reaches. In an
// isolated checkout that branch is the only copy of those commits; deleting it
// would leave them to the reflog (MUL-7284).
func deleteStaleAgentBranches(repoPath, keepBranch string) error {
return deleteStaleAgentBranchesContext(context.Background(), repoPath, keepBranch)
}
func deleteStaleAgentBranchesContext(ctx context.Context, repoPath, keepBranch string) error {
return deleteLocalBranchesUnderContext(ctx, repoPath, "refs/heads/agent/", "refs/heads/"+keepBranch)
keepRef := "refs/heads/" + keepBranch
return deleteLocalBranchesUnderContext(ctx, repoPath, "refs/heads/agent/", func(ref string) (bool, error) {
if ref == keepRef {
return true, nil
}
unpushed, err := countUnpushedCommitsContext(ctx, repoPath, ref)
return unpushed > 0, err
})
}
func deleteLocalBranchesUnder(repoPath, namespace, keepRef string) error {
return deleteLocalBranchesUnderContext(context.Background(), repoPath, namespace, keepRef)
// deleteLocalBranchesUnder deletes every branch under namespace that keep does
// not claim. A nil keep deletes them all.
func deleteLocalBranchesUnder(repoPath, namespace string, keep func(ref string) (bool, error)) error {
return deleteLocalBranchesUnderContext(context.Background(), repoPath, namespace, keep)
}
func deleteLocalBranchesUnderContext(ctx context.Context, repoPath, namespace, keepRef string) error {
func deleteLocalBranchesUnderContext(ctx context.Context, repoPath, namespace string, keep func(ref string) (bool, error)) error {
out, err := runGitOutputContext(ctx, "-C", repoPath, "for-each-ref", "--format=%(refname)", namespace)
if err != nil {
return fmt.Errorf("list local branches: %w", err)
}
for _, ref := range strings.Split(strings.TrimSpace(string(out)), "\n") {
ref = strings.TrimSpace(ref)
if ref == "" || ref == keepRef {
if ref == "" {
continue
}
if keep != nil {
kept, err := keep(ref)
if err != nil {
return err
}
if kept {
continue
}
}
if out, err := runGitCombinedOutputContext(ctx, "-C", repoPath, "update-ref", "-d", ref); err != nil {
return fmt.Errorf("delete local branch %s: %s: %w", ref, strings.TrimSpace(string(out)), err)
}
@@ -1398,10 +1491,112 @@ func isGitWorktree(path string) bool {
return err == nil && !info.IsDir()
}
// updateExistingCheckoutContext handles a checkout CreateWorktree finds already
// in place. Re-running checkout must never silently lose work (MUL-7284), and
// a reused workdir reaches here within one task (a repeated checkout), in a
// follow-up turn, and from a fresh session that has no memory of the directory.
// So unless fresh is set, a checkout keepReason claims is left exactly as it
// is. Only one with nothing to lose — or any, when fresh is set — is moved to a
// new branch from baseRef. The caller fetches beforehand, so a kept checkout
// still has current remote refs.
func updateExistingCheckoutContext(ctx context.Context, path, branchName, baseRef string, fresh bool) (*WorktreeResult, error) {
if !fresh {
state, err := inspectCheckoutContext(ctx, path)
if err != nil {
return nil, err
}
if state.Kept = keepReason(state, branchName); state.Kept != "" {
return state, nil
}
}
actualBranch, err := updateExistingWorktreeContext(ctx, path, branchName, baseRef)
if err != nil {
return nil, err
}
return &WorktreeResult{Path: path, BranchName: actualBranch}, nil
}
// keepReason says why an existing checkout must be left as it is, or returns
// "" when moving it to a new branch loses nothing. It is kept when it is
// already on this task's branch — the checkout was already done — or when it
// holds uncommitted changes, untracked files, or unpushed commits.
func keepReason(state *WorktreeResult, branchName string) string {
switch {
case isTaskBranch(state.BranchName, branchName):
return KeptTaskBranch
case state.UncommittedFiles > 0 || state.UnpushedCommits > 0:
return KeptLocalWork
default:
return ""
}
}
// inspectCheckoutContext describes what an existing checkout holds: its branch
// (empty on a detached HEAD), the paths `git status` reports, untracked files
// included, and the commits on HEAD that no remote-tracking ref reaches.
func inspectCheckoutContext(ctx context.Context, path string) (*WorktreeResult, error) {
result := &WorktreeResult{Path: path}
// symbolic-ref fails on a detached HEAD, which leaves BranchName empty.
if out, err := runGitOutputContext(ctx, "-C", path, "symbolic-ref", "--quiet", "HEAD"); err == nil {
result.BranchName = strings.TrimPrefix(strings.TrimSpace(string(out)), "refs/heads/")
}
// Untracked files are counted one by one because `git clean -fd` would
// delete each of them. Ignored files are not: nothing here deletes them.
out, err := runGitOutputContext(ctx, "-C", path, "status", "--porcelain", "--untracked-files=all")
if err != nil {
return nil, fmt.Errorf("inspect existing checkout %s: git status: %w", path, err)
}
for _, line := range strings.Split(string(out), "\n") {
if line != "" {
result.UncommittedFiles++
}
}
if result.UnpushedCommits, err = countUnpushedCommitsContext(ctx, path, "HEAD"); err != nil {
return nil, fmt.Errorf("inspect existing checkout %s: %w", path, err)
}
return result, nil
}
// countUnpushedCommitsContext counts the commits reachable from ref that no
// remote-tracking ref reaches: work whose only copy is this repository.
func countUnpushedCommitsContext(ctx context.Context, repoPath, ref string) (int, error) {
out, err := runGitOutputContext(ctx, "-C", repoPath, "rev-list", "--count", ref, "--not", "--remotes")
if err != nil {
return 0, fmt.Errorf("count unpushed commits on %s: %w", ref, err)
}
count, err := strconv.Atoi(strings.TrimSpace(string(out)))
if err != nil {
return 0, fmt.Errorf("count unpushed commits on %s: %w", ref, err)
}
return count, nil
}
// isTaskBranch reports whether branch is the one CreateWorktree gives the task
// that owns branchName: that name itself, or the timestamp-suffixed name a
// branch collision retry picks (see updateExistingWorktree).
func isTaskBranch(branch, branchName string) bool {
if branch == branchName {
return true
}
suffix, ok := strings.CutPrefix(branch, branchName+"-")
if !ok || suffix == "" {
return false
}
for _, r := range suffix {
if r < '0' || r > '9' {
return false
}
}
return true
}
// updateExistingWorktree resets the worktree to a clean state and checks out a
// new branch from the default branch. The caller is responsible for fetching
// the bare cache beforehand (worktrees share the same object store).
// Returns the actual branch name used (may differ from input on collision).
// new branch from the default branch, discarding uncommitted changes and
// untracked files. Callers go through updateExistingCheckoutContext, which
// reaches it only when that loses nothing or the caller asked for fresh. The
// caller is responsible for fetching the bare cache beforehand (worktrees share
// the same object store). Returns the actual branch name used (may differ from
// input on collision).
func updateExistingWorktree(worktreePath, branchName, baseRef string) (string, error) {
return updateExistingWorktreeContext(context.Background(), worktreePath, branchName, baseRef)
}
+64 -35
View File
@@ -890,6 +890,7 @@ func TestCreateIsolatedCheckoutImportsFetchedTipFromShallowCache(t *testing.T) {
taskBranch,
baseRef,
newTip,
"",
)
if err != nil {
t.Fatalf("create isolated checkout from shallow cache: %v", err)
@@ -911,17 +912,33 @@ func TestCreateWorktreeReusesIsolatedGitMetadata(t *testing.T) {
}
workDir := t.TempDir()
first, err := cache.CreateWorktree(WorktreeParams{
WorkspaceID: "ws-1",
RepoURL: sourceRepo,
WorkDir: workDir,
AgentName: "Linux Codex",
TaskID: "11111111-1111-1111-1111-111111111111",
IsolatedGitMetadata: true,
})
if err != nil {
t.Fatalf("first CreateWorktree failed: %v", err)
checkout := func(taskID string, fresh bool) *WorktreeResult {
t.Helper()
result, err := cache.CreateWorktree(WorktreeParams{
WorkspaceID: "ws-1",
RepoURL: sourceRepo,
WorkDir: workDir,
AgentName: "Linux Codex",
TaskID: taskID,
IsolatedGitMetadata: true,
Fresh: fresh,
})
if err != nil {
t.Fatalf("CreateWorktree(task %s, fresh=%v) failed: %v", taskID, fresh, err)
}
return result
}
localHeads := func(path string) string {
t.Helper()
heads, err := runGitOutput("-C", path, "for-each-ref", "--format=%(refname)", "refs/heads/")
if err != nil {
t.Fatalf("list local heads: %v", err)
}
return strings.TrimSpace(string(heads))
}
first := checkout("11111111-1111-1111-1111-111111111111", false)
addEmptyCommit(t, first.Path, "unpublished agent work")
const userBranch = "feature/keep-me"
runGitAuthored(t, first.Path, "checkout", "-b", userBranch)
addEmptyCommit(t, first.Path, "unpublished feature work")
@@ -933,17 +950,18 @@ func TestCreateWorktreeReusesIsolatedGitMetadata(t *testing.T) {
t.Fatalf("refresh sync failed: %v", err)
}
second, err := cache.CreateWorktree(WorktreeParams{
WorkspaceID: "ws-1",
RepoURL: sourceRepo,
WorkDir: workDir,
AgentName: "Linux Codex",
TaskID: "22222222-2222-2222-2222-222222222222",
IsolatedGitMetadata: true,
})
if err != nil {
t.Fatalf("second CreateWorktree failed: %v", err)
// Unpushed commits on HEAD keep the checkout exactly as it is.
kept := checkout("22222222-2222-2222-2222-222222222222", false)
if kept.Kept != KeptLocalWork || kept.BranchName != userBranch || kept.UnpushedCommits != 2 {
t.Fatalf("result = %+v, want %s kept with its 2 unpushed commits", kept, userBranch)
}
if got := gitHead(t, kept.Path); got != userCommit {
t.Fatalf("kept checkout HEAD = %s, want %s", got, userCommit)
}
// Fresh moves it to a new branch from the refreshed upstream, but an
// earlier task's agent/* branch holding unpushed commits is not pruned.
second := checkout("22222222-2222-2222-2222-222222222222", true)
if second.Path != first.Path {
t.Fatalf("reused checkout path = %q, want %q", second.Path, first.Path)
}
@@ -953,23 +971,26 @@ func TestCreateWorktreeReusesIsolatedGitMetadata(t *testing.T) {
if got := gitHead(t, second.Path); got != wantHead {
t.Fatalf("reused checkout HEAD = %s, want refreshed upstream %s", got, wantHead)
}
// Reuse must not accumulate earlier tasks' agent/* branches, but it must
// preserve user-created branches and commits that may not exist remotely.
if err := runGit("-C", second.Path, "show-ref", "--verify", "refs/heads/"+first.BranchName); err == nil {
t.Fatalf("stale branch %s survived reuse", first.BranchName)
}
if got := gitRefCommit(t, second.Path, "refs/heads/"+userBranch); got != userCommit {
t.Fatalf("preserved user branch commit = %s, want %s", got, userCommit)
}
heads, err := runGitOutput("-C", second.Path, "for-each-ref", "--format=%(refname)", "refs/heads/")
if err != nil {
t.Fatalf("list local heads: %v", err)
}
wantHeads := "refs/heads/" + second.BranchName + "\nrefs/heads/" + userBranch
if got := strings.TrimSpace(string(heads)); got != wantHeads {
wantHeads := "refs/heads/" + first.BranchName + "\nrefs/heads/" + second.BranchName + "\nrefs/heads/" + userBranch
if got := localHeads(second.Path); got != wantHeads {
t.Fatalf("reused checkout local heads = %q, want %q", got, wantHeads)
}
// Once pushed, the earlier task's branch is pruned like the second task's,
// which never left upstream: reuse must not accumulate agent/* branches.
// User-created branches are never pruned.
runGitAuthored(t, second.Path, "push", "origin", first.BranchName)
third := checkout("33333333-3333-3333-3333-333333333333", false)
if third.Kept != "" || third.BranchName == second.BranchName {
t.Fatalf("result = %+v, want the clean checkout moved to a new branch", third)
}
wantHeads = "refs/heads/" + third.BranchName + "\nrefs/heads/" + userBranch
if got := localHeads(third.Path); got != wantHeads {
t.Fatalf("reused checkout local heads = %q, want %q", got, wantHeads)
}
if got := gitRefCommit(t, third.Path, "refs/heads/"+userBranch); got != userCommit {
t.Fatalf("preserved user branch commit = %s, want %s", got, userCommit)
}
}
func TestCreateWorktreeMigratesLinkedWorktreeToIsolatedMetadata(t *testing.T) {
@@ -1012,6 +1033,14 @@ func TestCreateWorktreeMigratesLinkedWorktreeToIsolatedMetadata(t *testing.T) {
if !isIsolatedCheckout(isolated.Path) {
t.Fatal("linked worktree was not migrated to isolated metadata")
}
// The linked branch held nothing unpushed, so nothing is carried over.
heads, err := runGitOutput("-C", isolated.Path, "for-each-ref", "--format=%(refname)", "refs/heads/")
if err != nil {
t.Fatalf("list local heads: %v", err)
}
if got := strings.TrimSpace(string(heads)); got != "refs/heads/"+isolated.BranchName {
t.Fatalf("migrated checkout local heads = %q, want only %s", got, isolated.BranchName)
}
barePath := cache.Lookup("ws-1", sourceRepo)
out, err := runGitOutput("-C", barePath, "worktree", "list", "--porcelain")
@@ -0,0 +1,433 @@
package repocache
import (
"os"
"path/filepath"
"strings"
"testing"
)
// Re-running checkout on a path that already holds a checkout must never
// silently lose work (MUL-7284). These tests cover both checkout shapes: the
// linked worktree most runtimes get and the task-local clone Linux and
// Windows Codex get.
var checkoutModes = []struct {
name string
isolated bool
}{
{name: "linked", isolated: false},
{name: "isolated", isolated: true},
}
const (
firstTaskID = "11111111-1111-1111-1111-111111111111"
secondTaskID = "22222222-2222-2222-2222-222222222222"
)
// existingCheckoutFixture is a source repository with one tracked file, a
// cache synced from it, and one workdir every checkout in the test targets.
type existingCheckoutFixture struct {
source string
defaultBranch string
cache *Cache
workDir string
isolated bool
}
func newExistingCheckoutFixture(t *testing.T, isolated bool) *existingCheckoutFixture {
t.Helper()
source := createTestRepo(t)
if err := os.WriteFile(filepath.Join(source, "tracked.txt"), []byte("base\n"), 0o644); err != nil {
t.Fatalf("write tracked file: %v", err)
}
runGitAuthored(t, source, "add", "tracked.txt")
runGitAuthored(t, source, "commit", "-m", "add tracked file")
cache := New(t.TempDir(), testLogger())
if err := cache.Sync("ws-1", []RepoInfo{{URL: source}}); err != nil {
t.Fatalf("sync failed: %v", err)
}
return &existingCheckoutFixture{
source: source,
defaultBranch: currentBranchName(t, source),
cache: cache,
workDir: t.TempDir(),
isolated: isolated,
}
}
func (f *existingCheckoutFixture) checkout(t *testing.T, taskID string, fresh bool) *WorktreeResult {
t.Helper()
result, err := f.cache.CreateWorktree(WorktreeParams{
WorkspaceID: "ws-1",
RepoURL: f.source,
WorkDir: f.workDir,
AgentName: "Agent",
TaskID: taskID,
IsolatedGitMetadata: f.isolated,
Fresh: fresh,
})
if err != nil {
t.Fatalf("CreateWorktree(task %s, fresh=%v) failed: %v", taskID, fresh, err)
}
return result
}
// advanceUpstream commits to the source's default branch and syncs the cache,
// so a checkout that moved to the latest default branch would land on the
// returned commit.
func (f *existingCheckoutFixture) advanceUpstream(t *testing.T) string {
t.Helper()
addEmptyCommit(t, f.source, "upstream advance")
if err := f.cache.Sync("ws-1", []RepoInfo{{URL: f.source}}); err != nil {
t.Fatalf("refresh sync failed: %v", err)
}
return gitHead(t, f.source)
}
func taskBranchName(taskID string) string {
return "agent/agent/" + taskKey(taskID)
}
func localAgentBranches(t *testing.T, repoPath string) string {
t.Helper()
out, err := runGitOutput("-C", repoPath, "for-each-ref", "--format=%(refname)", "refs/heads/agent/")
if err != nil {
t.Fatalf("list agent branches: %v", err)
}
return strings.TrimSpace(string(out))
}
func readFile(t *testing.T, path string) string {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
return string(data)
}
func TestRepeatedCheckoutKeepsLocalWork(t *testing.T) {
t.Parallel()
kinds := []struct {
name string
makeWork func(t *testing.T, path string)
assertKept func(t *testing.T, path string)
wantUncommitted int
wantUnpushed int
}{
{
name: "uncommitted change",
makeWork: func(t *testing.T, path string) {
if err := os.WriteFile(filepath.Join(path, "tracked.txt"), []byte("edited\n"), 0o644); err != nil {
t.Fatalf("edit tracked file: %v", err)
}
},
assertKept: func(t *testing.T, path string) {
if got := readFile(t, filepath.Join(path, "tracked.txt")); got != "edited\n" {
t.Fatalf("tracked.txt = %q, want the uncommitted edit", got)
}
},
wantUncommitted: 1,
},
{
name: "untracked file",
makeWork: func(t *testing.T, path string) {
if err := os.WriteFile(filepath.Join(path, "new.txt"), []byte("new\n"), 0o644); err != nil {
t.Fatalf("write untracked file: %v", err)
}
},
assertKept: func(t *testing.T, path string) {
if got := readFile(t, filepath.Join(path, "new.txt")); got != "new\n" {
t.Fatalf("new.txt = %q, want the untracked file", got)
}
},
wantUncommitted: 1,
},
{
name: "unpushed commit",
makeWork: func(t *testing.T, path string) {
if err := os.WriteFile(filepath.Join(path, "committed.txt"), []byte("committed\n"), 0o644); err != nil {
t.Fatalf("write committed file: %v", err)
}
runGitAuthored(t, path, "add", "committed.txt")
runGitAuthored(t, path, "commit", "-m", "unpushed work")
},
assertKept: func(t *testing.T, path string) {
if got := readFile(t, filepath.Join(path, "committed.txt")); got != "committed\n" {
t.Fatalf("committed.txt = %q, want the committed file", got)
}
},
wantUnpushed: 1,
},
}
for _, mode := range checkoutModes {
for _, kind := range kinds {
t.Run(mode.name+"/"+kind.name, func(t *testing.T) {
t.Parallel()
f := newExistingCheckoutFixture(t, mode.isolated)
first := f.checkout(t, firstTaskID, false)
kind.makeWork(t, first.Path)
headBefore := gitHead(t, first.Path)
upstream := f.advanceUpstream(t)
second := f.checkout(t, secondTaskID, false)
if second.Kept != KeptLocalWork {
t.Fatalf("Kept = %q, want %q", second.Kept, KeptLocalWork)
}
if second.Path != first.Path || second.BranchName != first.BranchName {
t.Fatalf("result = %+v, want the existing checkout %s on %s", second, first.Path, first.BranchName)
}
if second.UncommittedFiles != kind.wantUncommitted || second.UnpushedCommits != kind.wantUnpushed {
t.Fatalf("reported %d uncommitted files and %d unpushed commits, want %d and %d",
second.UncommittedFiles, second.UnpushedCommits, kind.wantUncommitted, kind.wantUnpushed)
}
if got := currentBranchName(t, second.Path); got != first.BranchName {
t.Fatalf("checkout switched to %q, want it left on %q", got, first.BranchName)
}
if got := gitHead(t, second.Path); got != headBefore {
t.Fatalf("HEAD = %s, want it left at %s", got, headBefore)
}
kind.assertKept(t, second.Path)
if got := localAgentBranches(t, second.Path); got != "refs/heads/"+first.BranchName {
t.Fatalf("agent branches = %q, want only the kept %s", got, first.BranchName)
}
// Keeping the checkout still refreshes what it knows of the remote.
if got := gitRefCommit(t, second.Path, "refs/remotes/origin/"+f.defaultBranch); got != upstream {
t.Fatalf("origin/%s = %s, want the fetched upstream %s", f.defaultBranch, got, upstream)
}
})
}
}
}
func TestRepeatedCheckoutOnTaskBranchIsNoop(t *testing.T) {
t.Parallel()
for _, mode := range checkoutModes {
t.Run(mode.name, func(t *testing.T) {
t.Parallel()
f := newExistingCheckoutFixture(t, mode.isolated)
first := f.checkout(t, firstTaskID, false)
headBefore := gitHead(t, first.Path)
f.advanceUpstream(t)
again := f.checkout(t, firstTaskID, false)
if again.Kept != KeptTaskBranch {
t.Fatalf("Kept = %q, want %q", again.Kept, KeptTaskBranch)
}
if again.BranchName != first.BranchName {
t.Fatalf("branch = %q, want this task's %q", again.BranchName, first.BranchName)
}
if got := gitHead(t, again.Path); got != headBefore {
t.Fatalf("HEAD = %s, want it left at %s", got, headBefore)
}
// No timestamp-suffixed sibling of the task branch.
if got := localAgentBranches(t, again.Path); got != "refs/heads/"+first.BranchName {
t.Fatalf("agent branches = %q, want only %s", got, first.BranchName)
}
})
}
}
func TestRepeatedCheckoutMovesCleanCheckoutToNewBranch(t *testing.T) {
t.Parallel()
for _, mode := range checkoutModes {
t.Run(mode.name, func(t *testing.T) {
t.Parallel()
f := newExistingCheckoutFixture(t, mode.isolated)
first := f.checkout(t, firstTaskID, false)
// Committed and pushed work leaves nothing to lose.
if err := os.WriteFile(filepath.Join(first.Path, "pushed.txt"), []byte("pushed\n"), 0o644); err != nil {
t.Fatalf("write pushed file: %v", err)
}
runGitAuthored(t, first.Path, "add", "pushed.txt")
runGitAuthored(t, first.Path, "commit", "-m", "pushed work")
runGitAuthored(t, first.Path, "push", "origin", first.BranchName)
upstream := f.advanceUpstream(t)
second := f.checkout(t, secondTaskID, false)
if second.Kept != "" {
t.Fatalf("Kept = %q, want the clean checkout moved", second.Kept)
}
if second.BranchName != taskBranchName(secondTaskID) {
t.Fatalf("branch = %q, want %q", second.BranchName, taskBranchName(secondTaskID))
}
if got := gitHead(t, second.Path); got != upstream {
t.Fatalf("HEAD = %s, want the latest default branch %s", got, upstream)
}
if mode.isolated {
// The earlier task's branch is fully pushed, so pruning it loses nothing.
if got := localAgentBranches(t, second.Path); got != "refs/heads/"+second.BranchName {
t.Fatalf("agent branches = %q, want only %s", got, second.BranchName)
}
}
})
}
}
func TestFreshCheckoutDiscardsLocalWork(t *testing.T) {
t.Parallel()
for _, mode := range checkoutModes {
t.Run(mode.name, func(t *testing.T) {
t.Parallel()
f := newExistingCheckoutFixture(t, mode.isolated)
first := f.checkout(t, firstTaskID, false)
if err := os.WriteFile(filepath.Join(first.Path, "committed.txt"), []byte("committed\n"), 0o644); err != nil {
t.Fatalf("write committed file: %v", err)
}
runGitAuthored(t, first.Path, "add", "committed.txt")
runGitAuthored(t, first.Path, "commit", "-m", "unpushed work")
unpushed := gitHead(t, first.Path)
if err := os.WriteFile(filepath.Join(first.Path, "tracked.txt"), []byte("edited\n"), 0o644); err != nil {
t.Fatalf("edit tracked file: %v", err)
}
if err := os.WriteFile(filepath.Join(first.Path, "new.txt"), []byte("new\n"), 0o644); err != nil {
t.Fatalf("write untracked file: %v", err)
}
upstream := f.advanceUpstream(t)
fresh := f.checkout(t, secondTaskID, true)
if fresh.Kept != "" {
t.Fatalf("Kept = %q, want --fresh to start over", fresh.Kept)
}
if fresh.BranchName != taskBranchName(secondTaskID) {
t.Fatalf("branch = %q, want %q", fresh.BranchName, taskBranchName(secondTaskID))
}
if got := gitHead(t, fresh.Path); got != upstream {
t.Fatalf("HEAD = %s, want the latest default branch %s", got, upstream)
}
if got := readFile(t, filepath.Join(fresh.Path, "tracked.txt")); got != "base\n" {
t.Fatalf("tracked.txt = %q, want the uncommitted edit discarded", got)
}
for _, name := range []string{"new.txt", "committed.txt"} {
if _, err := os.Stat(filepath.Join(fresh.Path, name)); !os.IsNotExist(err) {
t.Fatalf("%s survived --fresh, err=%v", name, err)
}
}
// Starting over never deletes the only copy of committed work: the
// earlier branch keeps its unpushed commit in both shapes.
if got := gitRefCommit(t, fresh.Path, "refs/heads/"+first.BranchName); got != unpushed {
t.Fatalf("earlier branch %s = %s, want its unpushed commit %s", first.BranchName, got, unpushed)
}
})
}
}
// The linked worktree left by a pre-isolation daemon is migrated by removing
// it, which deletes its working tree. That must not happen to one holding work.
func TestIsolatedCheckoutKeepsLinkedWorktreeHoldingWork(t *testing.T) {
t.Parallel()
f := newExistingCheckoutFixture(t, false)
linked := f.checkout(t, firstTaskID, false)
if err := os.WriteFile(filepath.Join(linked.Path, "new.txt"), []byte("new\n"), 0o644); err != nil {
t.Fatalf("write untracked file: %v", err)
}
f.isolated = true
kept := f.checkout(t, secondTaskID, false)
if kept.Kept != KeptLocalWork || kept.UncommittedFiles != 1 {
t.Fatalf("result = %+v, want the linked worktree kept for its untracked file", kept)
}
if !isGitWorktree(kept.Path) || isIsolatedCheckout(kept.Path) {
t.Fatal("linked worktree holding work was migrated")
}
if got := readFile(t, filepath.Join(kept.Path, "new.txt")); got != "new\n" {
t.Fatalf("new.txt = %q, want the untracked file", got)
}
migrated := f.checkout(t, secondTaskID, true)
if migrated.Kept != "" || !isIsolatedCheckout(migrated.Path) {
t.Fatalf("result = %+v, want --fresh to migrate to isolated metadata", migrated)
}
}
// Fresh discards a migrated linked worktree's working tree, not its commits:
// a branch holding unpushed commits comes along into the isolated checkout.
// Left in the shared cache it would be out of the agent's reach and dropped by
// the next GC. No other cache branch comes along.
func TestFreshMigrationCarriesUnpushedBranch(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
taskID string
}{
{name: "another task", taskID: secondTaskID},
{name: "same task", taskID: firstTaskID},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
f := newExistingCheckoutFixture(t, false)
const foreignTaskID = "33333333-3333-3333-3333-333333333333"
if _, err := f.cache.CreateWorktree(WorktreeParams{
WorkspaceID: "ws-1",
RepoURL: f.source,
WorkDir: t.TempDir(),
AgentName: "Agent",
TaskID: foreignTaskID,
}); err != nil {
t.Fatalf("foreign CreateWorktree failed: %v", err)
}
linked := f.checkout(t, firstTaskID, false)
if err := os.WriteFile(filepath.Join(linked.Path, "committed.txt"), []byte("committed\n"), 0o644); err != nil {
t.Fatalf("write committed file: %v", err)
}
runGitAuthored(t, linked.Path, "add", "committed.txt")
runGitAuthored(t, linked.Path, "commit", "-m", "unpushed work")
unpushed := gitHead(t, linked.Path)
upstream := f.advanceUpstream(t)
f.isolated = true
migrated := f.checkout(t, tc.taskID, true)
if migrated.Kept != "" || !isIsolatedCheckout(migrated.Path) {
t.Fatalf("result = %+v, want --fresh to migrate to isolated metadata", migrated)
}
if got := gitHead(t, migrated.Path); got != upstream {
t.Fatalf("HEAD = %s, want the latest default branch %s", got, upstream)
}
if got := gitRefCommit(t, migrated.Path, "refs/heads/"+linked.BranchName); got != unpushed {
t.Fatalf("carried branch %s = %s, want its unpushed commit %s", linked.BranchName, got, unpushed)
}
if migrated.BranchName == linked.BranchName || !isTaskBranch(migrated.BranchName, taskBranchName(tc.taskID)) {
t.Fatalf("branch = %q, want a new branch for task %s beside the carried %s", migrated.BranchName, tc.taskID, linked.BranchName)
}
// Sorted by refname, as for-each-ref lists them.
heads := []string{"refs/heads/" + linked.BranchName, "refs/heads/" + migrated.BranchName}
if heads[1] < heads[0] {
heads[0], heads[1] = heads[1], heads[0]
}
if got, want := localAgentBranches(t, migrated.Path), strings.Join(heads, "\n"); got != want {
t.Fatalf("agent branches = %q, want only %q (no other task's cache branch)", got, want)
}
})
}
}
func TestIsTaskBranch(t *testing.T) {
t.Parallel()
const branch = "agent/agent/111111111111"
for _, tc := range []struct {
current string
want bool
}{
{current: branch, want: true},
{current: branch + "-1757570000", want: true},
{current: branch + "-", want: false},
{current: branch + "-fix", want: false},
{current: branch + "2", want: false},
{current: "agent/agent/222222222222", want: false},
{current: "", want: false},
} {
if got := isTaskBranch(tc.current, branch); got != tc.want {
t.Errorf("isTaskBranch(%q) = %v, want %v", tc.current, got, tc.want)
}
}
}
@@ -32,6 +32,7 @@ multica runtime update <runtime-id> --target-version <version> --output json
multica runtime delete <runtime-id>
multica repo checkout <url>
multica repo checkout <url> --ref <branch-or-sha>
multica repo checkout <url> --fresh
```
Runtime and repo commands affect active agent execution. Do not restart daemons,
@@ -55,6 +56,33 @@ runtimes use a linked worktree; Linux and Windows Codex use task-local Git
metadata so a task can stage and commit without making the shared repository
cache writable.
Running `repo checkout` again where the repository is already checked out
(the same task, a follow-up turn, or a reused workdir) never silently discards
work:
- a checkout with uncommitted changes, untracked files, or commits that no
remote ref reaches is kept exactly as it is — no reset, clean, branch switch,
or branch deletion — and only its remote refs are fetched;
- a checkout already on the current task's branch is treated as done, and only
its remote refs are fetched;
- a clean checkout with nothing unpushed on some other branch still moves to a
new branch from the latest default branch (or `--ref`).
When a checkout is kept, the command says so and reports its branch and how
many uncommitted files and unpushed commits it holds. `--fresh` discards the
existing checkout's uncommitted changes and untracked files and starts over on
a new branch from the latest default branch (or `--ref`). It deletes no branch
holding unpushed commits, so those commits stay on the old branch:
- with task-local Git metadata, the old branch stays in the checkout, including
when `--fresh` replaces a linked worktree left by an older daemon;
- with a linked worktree, the old branch lives in the daemon's shared
repository cache, whose periodic cleanup drops `agent/*` branches that no
checkout has checked out.
Push any commits you still need before using `--fresh`. This needs a daemon
that includes the change; older daemons always start over.
`repo checkout` requires both `MULTICA_DAEMON_PORT` and the injected task-scoped
`MULTICA_TOKEN`; it is intended to run inside the active daemon task and from
that task's workdir (or a descendant). The local daemon authenticates the token