From 99a87091cb7db443198421f39f1ffeef17112e22 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 18 Sep 2026 16:54:50 +0200 Subject: [PATCH 1/4] Remove git worktrees from the connector, completely MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The connector should not care where work happens. It runs where it is started, and the agent decides the rest — from its skills, its CLAUDE.md, its memory. A task that needs a clone or a directory of its own makes one. Sandboxing is being built separately and is where containment belongs. Worktrees existed so two tasks on one repository could run side by side. That is a concurrency answer, and concurrency is not what they are being kept for: two workers in one directory is managed by the people running them until boxes are real. What goes, in whole files: worktrees.go, ledger_worktrees.go, connect_worktrees.go and their tests — about 5,400 lines — plus the references in twenty-odd others. `connect worktrees list` and `prune` go with them, and .surface records the removal. Worktrees turned out to be the only implementation of the Workspaces seam, so the seam went too: PerTaskWorkspaces and WaitingWorkspaces are worktree concepts by name, and RecoveringWorkspaces existed to reconcile worktrees on start. A task's working directory is its route, as it already was whenever worktrees were off, and the dispatcher prepares nothing. That cascaded once more. Nothing can now raise ErrRouteUnusable — only a worktree that could not be made ever did — so Ledger.RefuseStart, the StartRefused hook, ReasonRouteUnusable, refusedStartKey and renderRefusedStart are all unreachable and go with it. A route directory that is missing or unreadable is unchanged from the worktrees-off path it already took: the spawn fails, is retried once, and the record is blocked spawn_failed with a notice on the card. The lifecycle map keeps its admitted→blocked edges; shipped transition rules are not edited for a caller that left. The ledger keeps migration 9, byte for byte. A migration that has been applied is never taken out of the list — removing it would change what every later one means, and a fresh ledger has to walk the same numbers — so migration 12 drops the table instead. The directories are not touched: a worktree the connector kept is still on disk and still git's, and `git worktree remove` still removes it. connect.json keeps a read-and-discard LegacyWorktrees field. **Do not tidy it away.** Parse uses DisallowUnknownFields, so an unknown key is a refusal; every connect.json ever written carries "worktrees", and deleting the field outright would stop every connector that has been set up. It is zeroed after the decode and omitempty keeps it out of anything written, so the next `connect setup` takes the key out for good. --- .surface | 73 - .surface-breaking | 73 + e2e/smoke/smoke_lifecycle.bats | 8 - internal/commands/commands.go | 2 +- internal/commands/connect.go | 40 +- internal/commands/connect_doctor.go | 166 +- internal/commands/connect_operator.go | 21 +- internal/commands/connect_operator_test.go | 277 +- internal/commands/connect_run.go | 16 +- internal/commands/connect_worktrees.go | 332 --- internal/commands/connect_worktrees_test.go | 155 -- internal/connector/admission/policy.go | 2 +- .../connector/dispatch_route_refusal_test.go | 206 -- internal/connector/dispatcher.go | 129 +- .../connector/dispatcher_boundary_test.go | 19 +- internal/connector/dispatcher_test.go | 104 +- internal/connector/ledger.go | 78 +- internal/connector/ledger_dispatch.go | 6 +- internal/connector/ledger_migrations_test.go | 60 + internal/connector/ledger_status.go | 53 +- internal/connector/ledger_tasks.go | 74 +- internal/connector/ledger_worktrees.go | 390 --- internal/connector/lifecycle.go | 64 +- internal/connector/operator_status_test.go | 53 +- internal/connector/outbox.go | 24 +- internal/connector/outbox_run.go | 14 +- internal/connector/recovery_connector_test.go | 27 +- internal/connector/recovery_dispatch_test.go | 11 +- internal/connector/recovery_harness_test.go | 45 +- internal/connector/retraction_test.go | 58 +- internal/connector/setup/apply.go | 47 - internal/connector/setup/apply_test.go | 5 +- internal/connector/setup/checks.go | 19 +- internal/connector/setup/file.go | 13 +- internal/connector/setup/file_test.go | 31 +- .../connector/setup/route_repository_test.go | 73 - internal/connector/worktrees.go | 2237 ----------------- internal/connector/worktrees_route_test.go | 66 - .../connector/worktrees_route_unix_test.go | 39 - internal/connector/worktrees_test.go | 2153 ---------------- .../not-ready-agent-reads.yml | 2 +- .../cases/basecamp-connect/route-quoting.yml | 2 +- skills/basecamp-connect/SKILL.md | 11 +- skills/basecamp/SKILL.md | 17 +- 44 files changed, 395 insertions(+), 6900 deletions(-) delete mode 100644 internal/commands/connect_worktrees.go delete mode 100644 internal/commands/connect_worktrees_test.go delete mode 100644 internal/connector/dispatch_route_refusal_test.go create mode 100644 internal/connector/ledger_migrations_test.go delete mode 100644 internal/connector/ledger_worktrees.go delete mode 100644 internal/connector/setup/route_repository_test.go delete mode 100644 internal/connector/worktrees.go delete mode 100644 internal/connector/worktrees_route_test.go delete mode 100644 internal/connector/worktrees_route_unix_test.go delete mode 100644 internal/connector/worktrees_test.go diff --git a/.surface b/.surface index e3f8c2835..618cfc90f 100644 --- a/.surface +++ b/.surface @@ -682,9 +682,6 @@ CMD basecamp connect shadow CMD basecamp connect shadow promote CMD basecamp connect show CMD basecamp connect status -CMD basecamp connect worktrees -CMD basecamp connect worktrees list -CMD basecamp connect worktrees prune CMD basecamp docs CMD basecamp docs archive CMD basecamp docs doc @@ -5524,7 +5521,6 @@ FLAG basecamp connect setup --trust type=string FLAG basecamp connect setup --verbose type=count FLAG basecamp connect setup --watch-completions type=stringArray FLAG basecamp connect setup --worker type=string -FLAG basecamp connect setup --worktrees type=bool FLAG basecamp connect shadow --account type=string FLAG basecamp connect shadow --agent type=bool FLAG basecamp connect shadow --cache-dir type=string @@ -5610,72 +5606,6 @@ FLAG basecamp connect status --stats type=bool FLAG basecamp connect status --styled type=bool FLAG basecamp connect status --todolist type=string FLAG basecamp connect status --verbose type=count -FLAG basecamp connect worktrees --account type=string -FLAG basecamp connect worktrees --agent type=bool -FLAG basecamp connect worktrees --cache-dir type=string -FLAG basecamp connect worktrees --count type=bool -FLAG basecamp connect worktrees --help type=bool -FLAG basecamp connect worktrees --hints type=bool -FLAG basecamp connect worktrees --ids-only type=bool -FLAG basecamp connect worktrees --in type=string -FLAG basecamp connect worktrees --jq type=string -FLAG basecamp connect worktrees --json type=bool -FLAG basecamp connect worktrees --markdown type=bool -FLAG basecamp connect worktrees --md type=bool -FLAG basecamp connect worktrees --no-hints type=bool -FLAG basecamp connect worktrees --no-stats type=bool -FLAG basecamp connect worktrees --profile type=string -FLAG basecamp connect worktrees --project type=string -FLAG basecamp connect worktrees --quiet type=bool -FLAG basecamp connect worktrees --stats type=bool -FLAG basecamp connect worktrees --styled type=bool -FLAG basecamp connect worktrees --todolist type=string -FLAG basecamp connect worktrees --verbose type=count -FLAG basecamp connect worktrees list --account type=string -FLAG basecamp connect worktrees list --agent type=bool -FLAG basecamp connect worktrees list --cache-dir type=string -FLAG basecamp connect worktrees list --count type=bool -FLAG basecamp connect worktrees list --help type=bool -FLAG basecamp connect worktrees list --hints type=bool -FLAG basecamp connect worktrees list --ids-only type=bool -FLAG basecamp connect worktrees list --in type=string -FLAG basecamp connect worktrees list --jq type=string -FLAG basecamp connect worktrees list --json type=bool -FLAG basecamp connect worktrees list --markdown type=bool -FLAG basecamp connect worktrees list --md type=bool -FLAG basecamp connect worktrees list --no-hints type=bool -FLAG basecamp connect worktrees list --no-stats type=bool -FLAG basecamp connect worktrees list --profile type=string -FLAG basecamp connect worktrees list --project type=string -FLAG basecamp connect worktrees list --quiet type=bool -FLAG basecamp connect worktrees list --shadow type=bool -FLAG basecamp connect worktrees list --stats type=bool -FLAG basecamp connect worktrees list --styled type=bool -FLAG basecamp connect worktrees list --todolist type=string -FLAG basecamp connect worktrees list --verbose type=count -FLAG basecamp connect worktrees prune --account type=string -FLAG basecamp connect worktrees prune --agent type=bool -FLAG basecamp connect worktrees prune --cache-dir type=string -FLAG basecamp connect worktrees prune --count type=bool -FLAG basecamp connect worktrees prune --force type=stringArray -FLAG basecamp connect worktrees prune --help type=bool -FLAG basecamp connect worktrees prune --hints type=bool -FLAG basecamp connect worktrees prune --ids-only type=bool -FLAG basecamp connect worktrees prune --in type=string -FLAG basecamp connect worktrees prune --jq type=string -FLAG basecamp connect worktrees prune --json type=bool -FLAG basecamp connect worktrees prune --markdown type=bool -FLAG basecamp connect worktrees prune --md type=bool -FLAG basecamp connect worktrees prune --no-hints type=bool -FLAG basecamp connect worktrees prune --no-stats type=bool -FLAG basecamp connect worktrees prune --profile type=string -FLAG basecamp connect worktrees prune --project type=string -FLAG basecamp connect worktrees prune --quiet type=bool -FLAG basecamp connect worktrees prune --shadow type=bool -FLAG basecamp connect worktrees prune --stats type=bool -FLAG basecamp connect worktrees prune --styled type=bool -FLAG basecamp connect worktrees prune --todolist type=string -FLAG basecamp connect worktrees prune --verbose type=count FLAG basecamp docs --account type=string FLAG basecamp docs --agent type=bool FLAG basecamp docs --cache-dir type=string @@ -18865,9 +18795,6 @@ SUB basecamp connect shadow SUB basecamp connect shadow promote SUB basecamp connect show SUB basecamp connect status -SUB basecamp connect worktrees -SUB basecamp connect worktrees list -SUB basecamp connect worktrees prune SUB basecamp docs SUB basecamp docs archive SUB basecamp docs doc diff --git a/.surface-breaking b/.surface-breaking index ff2d93791..90a035eed 100644 --- a/.surface-breaking +++ b/.surface-breaking @@ -66,6 +66,9 @@ CMD basecamp card move CMD basecamp card mv CMD basecamp card update CMD basecamp comment +CMD basecamp connect worktrees +CMD basecamp connect worktrees list +CMD basecamp connect worktrees prune CMD basecamp done CMD basecamp mcp CMD basecamp message @@ -479,6 +482,73 @@ FLAG basecamp comments --on type=string FLAG basecamp comments --page type=int FLAG basecamp comments list --on type=string FLAG basecamp comments update --content type=string +FLAG basecamp connect setup --worktrees type=bool +FLAG basecamp connect worktrees --account type=string +FLAG basecamp connect worktrees --agent type=bool +FLAG basecamp connect worktrees --cache-dir type=string +FLAG basecamp connect worktrees --count type=bool +FLAG basecamp connect worktrees --help type=bool +FLAG basecamp connect worktrees --hints type=bool +FLAG basecamp connect worktrees --ids-only type=bool +FLAG basecamp connect worktrees --in type=string +FLAG basecamp connect worktrees --jq type=string +FLAG basecamp connect worktrees --json type=bool +FLAG basecamp connect worktrees --markdown type=bool +FLAG basecamp connect worktrees --md type=bool +FLAG basecamp connect worktrees --no-hints type=bool +FLAG basecamp connect worktrees --no-stats type=bool +FLAG basecamp connect worktrees --profile type=string +FLAG basecamp connect worktrees --project type=string +FLAG basecamp connect worktrees --quiet type=bool +FLAG basecamp connect worktrees --stats type=bool +FLAG basecamp connect worktrees --styled type=bool +FLAG basecamp connect worktrees --todolist type=string +FLAG basecamp connect worktrees --verbose type=count +FLAG basecamp connect worktrees list --account type=string +FLAG basecamp connect worktrees list --agent type=bool +FLAG basecamp connect worktrees list --cache-dir type=string +FLAG basecamp connect worktrees list --count type=bool +FLAG basecamp connect worktrees list --help type=bool +FLAG basecamp connect worktrees list --hints type=bool +FLAG basecamp connect worktrees list --ids-only type=bool +FLAG basecamp connect worktrees list --in type=string +FLAG basecamp connect worktrees list --jq type=string +FLAG basecamp connect worktrees list --json type=bool +FLAG basecamp connect worktrees list --markdown type=bool +FLAG basecamp connect worktrees list --md type=bool +FLAG basecamp connect worktrees list --no-hints type=bool +FLAG basecamp connect worktrees list --no-stats type=bool +FLAG basecamp connect worktrees list --profile type=string +FLAG basecamp connect worktrees list --project type=string +FLAG basecamp connect worktrees list --quiet type=bool +FLAG basecamp connect worktrees list --shadow type=bool +FLAG basecamp connect worktrees list --stats type=bool +FLAG basecamp connect worktrees list --styled type=bool +FLAG basecamp connect worktrees list --todolist type=string +FLAG basecamp connect worktrees list --verbose type=count +FLAG basecamp connect worktrees prune --account type=string +FLAG basecamp connect worktrees prune --agent type=bool +FLAG basecamp connect worktrees prune --cache-dir type=string +FLAG basecamp connect worktrees prune --count type=bool +FLAG basecamp connect worktrees prune --force type=stringArray +FLAG basecamp connect worktrees prune --help type=bool +FLAG basecamp connect worktrees prune --hints type=bool +FLAG basecamp connect worktrees prune --ids-only type=bool +FLAG basecamp connect worktrees prune --in type=string +FLAG basecamp connect worktrees prune --jq type=string +FLAG basecamp connect worktrees prune --json type=bool +FLAG basecamp connect worktrees prune --markdown type=bool +FLAG basecamp connect worktrees prune --md type=bool +FLAG basecamp connect worktrees prune --no-hints type=bool +FLAG basecamp connect worktrees prune --no-stats type=bool +FLAG basecamp connect worktrees prune --profile type=string +FLAG basecamp connect worktrees prune --project type=string +FLAG basecamp connect worktrees prune --quiet type=bool +FLAG basecamp connect worktrees prune --shadow type=bool +FLAG basecamp connect worktrees prune --stats type=bool +FLAG basecamp connect worktrees prune --styled type=bool +FLAG basecamp connect worktrees prune --todolist type=string +FLAG basecamp connect worktrees prune --verbose type=count FLAG basecamp docs doc create --content type=string FLAG basecamp docs doc create --title type=string FLAG basecamp docs document create --content type=string @@ -1005,6 +1075,9 @@ SUB basecamp card move SUB basecamp card mv SUB basecamp card update SUB basecamp comment +SUB basecamp connect worktrees +SUB basecamp connect worktrees list +SUB basecamp connect worktrees prune SUB basecamp done SUB basecamp mcp SUB basecamp message diff --git a/e2e/smoke/smoke_lifecycle.bats b/e2e/smoke/smoke_lifecycle.bats index f0cbdb5f5..2c052c281 100644 --- a/e2e/smoke/smoke_lifecycle.bats +++ b/e2e/smoke/smoke_lifecycle.bats @@ -52,14 +52,6 @@ load smoke_helper mark_out_of_scope "Applies a reconciliation file to a local connector ledger — covered by Go tests in internal/commands and internal/connector" } -@test "connect worktrees list is out of scope" { - mark_out_of_scope "Reads a local connector's ledger — covered by Go tests in internal/commands and internal/connector" -} - -@test "connect worktrees prune is out of scope" { - mark_out_of_scope "Removes local git worktrees a connector kept — covered by Go tests in internal/commands and internal/connector" -} - @test "auth refresh is out of scope" { mark_out_of_scope "Requires OAuth credentials" } diff --git a/internal/commands/commands.go b/internal/commands/commands.go index bf7ea0626..a742a76a3 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -146,7 +146,7 @@ func CommandCategories() []CommandCategory { {Name: "bonfire", Category: "additional", Description: "Multi-chat orchestration", Actions: []string{"split", "layout"}, Experimental: true, DevOnly: true}, {Name: "api", Category: "additional", Description: "Raw API access"}, {Name: "mcp", Category: "additional", Description: "Serve Basecamp to MCP clients over stdio"}, - {Name: "connect", Category: "additional", Description: "Run a local agent connector for a Basecamp agent, and see and decide what it runs", Actions: []string{"setup", "show", "status", "doctor", "redispatch", "discard", "release", "shadow", "import", "worktrees"}}, + {Name: "connect", Category: "additional", Description: "Run a local agent connector for a Basecamp agent, and see and decide what it runs", Actions: []string{"setup", "show", "status", "doctor", "redispatch", "discard", "release", "shadow", "import"}}, {Name: "help", Category: "additional", Description: "Show help"}, {Name: "version", Category: "additional", Description: "Show version"}, }, diff --git a/internal/commands/connect.go b/internal/commands/connect.go index 138c64cec..557b51751 100644 --- a/internal/commands/connect.go +++ b/internal/commands/connect.go @@ -75,8 +75,7 @@ wait for review, until basecamp connect release. Linux only. } addConnectRunFlags(cmd, &run) cmd.AddCommand(newConnectSetupCmd(), newConnectWorkerMCPCmd(), newConnectShowCmd(), newConnectStatusCmd(), newConnectDoctorCmd(), - newConnectRedispatchCmd(), newConnectDiscardCmd(), newConnectReleaseCmd(), newConnectShadowCmd(), newConnectImportCmd(), - newConnectWorktreesCmd()) + newConnectRedispatchCmd(), newConnectDiscardCmd(), newConnectReleaseCmd(), newConnectShadowCmd(), newConnectImportCmd()) return cmd } @@ -181,10 +180,6 @@ func connectShowDisplay(path string, f setup.File, markdown bool) map[string]any } trust += ": people " + strings.Join(ids, ", ") } - worktrees := "off" - if f.Worktrees { - worktrees = "on" - } d := map[string]any{ "file": exact(path), "account": f.AccountID, @@ -195,7 +190,7 @@ func connectShowDisplay(path string, f setup.File, markdown bool) map[string]any // agent the driver runs, and a file written before that field // existed still means the default, which is what a person reading // show needs to see. - "workers": fmt.Sprintf("%s running %s, concurrency %d, deadline %s, worktrees %s", f.Driver, f.WorkerName(), f.Concurrency, time.Duration(f.Deadline), worktrees), + "workers": fmt.Sprintf("%s running %s, concurrency %d, deadline %s", f.Driver, f.WorkerName(), f.Concurrency, time.Duration(f.Deadline)), "projects": strconv.Itoa(len(f.Projects)) + " routed", } for id, r := range f.Projects { @@ -265,16 +260,15 @@ type connectSetupFlags struct { trust string allow []string - routes []string - classes []string - watch []string - unwatch []string - unroute []string - driver string - worker string - parallel int - deadline time.Duration - worktrees bool + routes []string + classes []string + watch []string + unwatch []string + unroute []string + driver string + worker string + parallel int + deadline time.Duration } func newConnectSetupCmd() *cobra.Command { @@ -331,7 +325,7 @@ Examples: basecamp connect setup -P agent --operator-profile me --route 12345=~/Work/app basecamp connect setup -P agent --operator-profile me --trust allowlist --allow 111 --allow 222 basecamp connect setup -P bot --operator-profile me --expect-identity 4242 --route 12345=~/Work/app - basecamp connect setup -P agent --class 12345=internal --deadline 90m --worktrees`, + basecamp connect setup -P agent --class 12345=internal --deadline 90m`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { app := appctx.FromContext(cmd.Context()) @@ -357,7 +351,6 @@ Examples: fl.StringVar(&f.worker, "worker", "", fmt.Sprintf("The coding agent workers run: %s (default %s)", strings.Join(setup.Workers, ", "), setup.DefaultWorker)) fl.IntVar(&f.parallel, "concurrency", 0, fmt.Sprintf("Workers at once (default %d)", setup.DefaultConcurrency)) fl.DurationVar(&f.deadline, "deadline", 0, fmt.Sprintf("Deadline per task (default %s)", setup.DefaultDeadline)) - fl.BoolVar(&f.worktrees, "worktrees", false, "Give each task its own git worktree") cmd.MarkFlagsMutuallyExclusive("operator", "operator-profile") return cmd @@ -381,7 +374,6 @@ func runConnectSetup(cmd *cobra.Command, app *appctx.App, f *connectSetupFlags) if err != nil { return err } - worktreesChanged(cmd, f, &changes) expect, err := parseExpectIdentity(f.expectIdentity) if err != nil { return err @@ -810,14 +802,6 @@ func (f *connectSetupFlags) changes(cmd *cobra.Command) (setup.Changes, error) { return ch, nil } -// worktreesChanged wires --worktrees, which is only a change when typed. -func worktreesChanged(cmd *cobra.Command, f *connectSetupFlags, ch *setup.Changes) { - if cmd.Flags().Changed("worktrees") { - v := f.worktrees - ch.Worktrees = &v - } -} - // parseProjectPairs parses repeatable = flags. func parseProjectPairs(flag string, raw []string) (map[int64]string, error) { return parseProjectPairsWith(flag, raw, false) diff --git a/internal/commands/connect_doctor.go b/internal/commands/connect_doctor.go index c5170184c..fcefda263 100644 --- a/internal/commands/connect_doctor.go +++ b/internal/commands/connect_doctor.go @@ -35,13 +35,12 @@ func newConnectDoctorCmd() *cobra.Command { Short: "Check what the connector needs to run", Long: `Check the connector for a set-up profile: connect.json, the token, the agent's identity, the stream ticket mint, the account feed, the ledger (its gaps, open -losses, hold, the worktrees it kept and messages waiting for a person), the +losses, hold and messages waiting for a person), the worker the driver runs — the worker's own CLI on PATH under the spawn driver, the pinned ACP adapter in the connector's adapters directory under the acp driver, and the adapter's own refusal of configuration on this machine that the connector cannot switch off, run in the directory every routed project -would work in (its worktree, where a profile makes them) — and a -handshake with the agent's Basecamp MCP server, started with a worker's +would work in — and a handshake with the agent's Basecamp MCP server, started with a worker's environment (without the basecamp_connect domain, which only a dispatched task's token opens). @@ -80,7 +79,7 @@ func runConnectDoctor(cmd *cobra.Command, _ []string) error { ) } checks = append(checks, ledgerChecks(ctx, p)...) - checks = append(checks, workerBinaryChecks(ctx, p.file)...) + checks = append(checks, workerBinaryChecks(p.file)...) checks = append(checks, mcpHandshakeCheck(ctx, p.name)) result := summarizeChecks(asDoctorChecks(checks)) @@ -152,7 +151,7 @@ func ledgerChecks(ctx context.Context, p connectProfile) []setup.Check { return []setup.Check{{Name: "Ledger", Status: setup.StatusFail, Message: errorMessage(err)}} } defer func() { _ = ledger.Close() }() - s, err := ledger.Status(ctx, ledger.RetainedWorktreeStatus) + s, err := ledger.Status(ctx) if err != nil { return []setup.Check{{Name: "Ledger", Status: setup.StatusFail, Message: errorMessage(err)}} } @@ -177,16 +176,6 @@ func ledgerChecks(ctx context.Context, p connectProfile) []setup.Check { checks = append(checks, setup.Check{Name: "Lifecycle messages", Status: setup.StatusWarn, Message: fmt.Sprintf("%d messages may or may not have been posted and wait for a person", len(s.Indeterminate))}) } - switch { - case !s.WorktreesKnown: - checks = append(checks, setup.Check{Name: "Worktrees", Status: setup.StatusWarn, - Message: "The worktrees the connector kept could not be listed: " + richtext.SanitizeSingleLine(s.WorktreesUnavailable), - Hint: "basecamp connect worktrees list -P " + shellQuote(p.name)}) - case len(s.Worktrees) > 0: - checks = append(checks, setup.Check{Name: "Worktrees", Status: setup.StatusWarn, - Message: fmt.Sprintf("%d worktree(s) kept for you to deal with; the connector removes none of its own accord", len(s.Worktrees)), - Hint: "basecamp connect worktrees list -P " + shellQuote(p.name) + ", then prune"}) - } return checks } @@ -205,10 +194,10 @@ func workerBinaries(file setup.File) []string { // doctor passes the adapter the connector would start and no other. PATH // would both fail a correct install and pass an unpinned build that happens // to be on it. -func workerBinaryChecks(ctx context.Context, file setup.File) []setup.Check { +func workerBinaryChecks(file setup.File) []setup.Check { if file.Driver == setup.DriverACP { checks := []setup.Check{acpAdapterCheck(file.WorkerName())} - if c, ok := acpPreflightCheck(ctx, file); ok { + if c, ok := acpPreflightCheck(file); ok { checks = append(checks, c) } return checks @@ -276,29 +265,14 @@ func acpAdapterCheck(worker string) setup.Check { // user's and the system's — fail identically, so an identical reason is // reported once, for all of them. // -// A layer inside a planned worktree that is not a regular file the -// repository tracks — a symbolic link, a submodule — is not modeled and is -// reported as a layer that could not be checked. Doctor never calls a -// profile ready on the strength of a layer nothing read, which is the bug -// this check exists for; what it does not do is model git. -// // What it runs against is the directory a dispatch would give the session, -// which is the route only while worktrees are off. With them on the session -// works in a worktree under the connector's state directory, and the layers -// there are not the route's: a .codex/config.toml the route has but the -// repository does not track is in no worktree, one above the route is not -// above the worktree, and one above the state directory is above every -// worktree and above no route. So the worktree is planned — the same -// decision Prepare makes, making nothing — and the preflight runs in the -// planned working directory, reading the files inside it from the commit -// the checkout would be made at. A route that could take no worktree at all -// is a route no task could run in, and fails here rather than at the first -// dispatch. +// which is the route itself: the connector runs a task where its route +// says, and prepares nothing. // // The second return is false when there is nothing to run: an adapter with // no preflight (claude-agent-acp) gets no row, rather than a row saying a // check that does not exist passed. -func acpPreflightCheck(ctx context.Context, file setup.File) (setup.Check, bool) { +func acpPreflightCheck(file setup.File) (setup.Check, bool) { a, ok := acp.AdapterForWorker(file.WorkerName()) if !ok || a.Preflight == nil { return setup.Check{}, false @@ -315,7 +289,6 @@ func acpPreflightCheck(ctx context.Context, file setup.File) (setup.Check, bool) return c, true } - planner, plannerErr := preflightPlanner(file) type failure struct { reason string routes []string @@ -326,14 +299,11 @@ func acpPreflightCheck(ctx context.Context, file setup.File) (setup.Check, bool) seen := map[string]*failure{} for _, id := range ids { path := file.Projects[id].Path - errs := []error{plannerErr} - if plannerErr == nil { - // Each refusal apart, not the session's whole refusal: a layer - // every route shares is one reason for all of them, and a route - // that has a second one of its own must not turn the shared one - // into a reason of its own too. - errs = preflightRoute(ctx, a, planner, path) - } + // Each refusal apart, not the session's whole refusal: a layer every + // route shares is one reason for all of them, and a route that has a + // second one of its own must not turn the shared one into a reason + // of its own too. + errs := acp.Refusals(acp.Preflight(a, path, nil, nil)) for _, err := range errs { if err == nil { continue @@ -351,7 +321,7 @@ func acpPreflightCheck(ctx context.Context, file setup.File) (setup.Check, bool) } if len(failures) == 0 { c.Status = setup.StatusPass - c.Message = fmt.Sprintf("%s would start in %s (%d checked)", a.Name, preflightWhere(file, "every routed directory", "the worktree every routed project would get"), len(ids)) + c.Message = fmt.Sprintf("%s would start in every routed directory (%d checked)", a.Name, len(ids)) return c, true } @@ -362,7 +332,7 @@ func acpPreflightCheck(ctx context.Context, file setup.File) (setup.Check, bool) if len(ids) > 1 && len(f.routes) == len(ids) { // Every route, one reason: the user's or the system's layer, // which no route escapes by being somewhere else. - where = preflightWhere(file, "any routed directory", "any routed project's worktree") + where = "any routed directory" } lead := "no session would start in" if i == 0 { @@ -393,18 +363,9 @@ func acpPreflightCheck(ctx context.Context, file setup.File) (setup.Check, bool) // refusal that is certainly a blocked session outranks one that only says // nothing could be checked. func preflightHint(err error) (string, int) { - var unmodeled *connector.UnmodeledPathError switch { - case errors.Is(err, connector.ErrRouteUnusable): - return "With worktrees on, a route has to be a directory a git repository tracks, in a repository with a commit — git tracks a directory through the files in it, so an untracked or ignored one is in no checkout. Commit it, route the project elsewhere, or run the profile without worktrees.", 0 - case errors.Is(err, errNoWorkDir): - // Not proved to be the route itself (#753): git, or the directory, - // could not be read this once, and a dispatch would try again. - return "Doctor could not tell whether a task would get a working directory there; run it again, and check that git runs and the directory can be read.", 0 case errors.Is(err, acp.ErrForeignMCPConfig): return "Take the MCP servers out of that file (a key an escape hides counts), or start the connector with CODEX_HOME set to a Codex home that declares none.", 1 - case errors.As(err, &unmodeled): - return "Read that file yourself before you trust the profile — doctor models a planned worktree's regular files and nothing else — or run without worktrees, where the check reads what is there.", 2 case errors.Is(err, acp.ErrConfigUnreadable): return "Make that file readable by the user the connector runs as, or remove it: while it cannot be read, nothing can tell whether it declares MCP servers.", 3 default: @@ -412,101 +373,6 @@ func preflightHint(err error) (string, int) { } } -// preflightWhere names the directory the check ran in: the route itself, or -// the worktree a task on it would be given. Saying "routed directory" while -// checking a worktree would be the same silence this check exists to end. -func preflightWhere(file setup.File, route, worktree string) string { - if file.Worktrees { - return worktree - } - return route -} - -// preflightPlanner plans the worktrees a profile with worktrees on would -// make: nil when they are off, and the session works in the route itself. -func preflightPlanner(file setup.File) (*connector.Worktrees, error) { - if !file.Worktrees { - return nil, nil //nolint:nilnil // no planner and no error is "the route is the working directory" - } - stateDir, err := connectStatePath(file, false) - if err != nil { - return nil, err - } - // The root the run command gives Worktrees, without making it: doctor - // writes nothing, and a root that does not exist yet holds no - // configuration either. - return connector.PlanWorktrees(connector.WorktreesOptions{Root: filepath.Join(stateDir, connectWorktreesDir)}) -} - -// preflightWorktreeName stands in for the name Prepare gives a task's -// worktree. Any name is the same directory as far as configuration goes: -// what a task's own name would add is one directory level that does not -// exist until the worktree is made, and whose files are the repository's. -const preflightWorktreeName = "doctor" - -// preflightRoute runs the preflight for one route, in the directory a -// dispatch on it would use, and gives back every refusal on its own. -func preflightRoute(ctx context.Context, a acp.Adapter, planner *connector.Worktrees, route string) []error { - if planner == nil { - return acp.Refusals(acp.Preflight(a, route, nil, nil)) - } - plan, err := planner.Plan(ctx, route, preflightWorktreeName) - if err != nil { - // A route that can take no worktree takes no task either: every - // dispatch on it waits in a backoff nothing reports. - return []error{fmt.Errorf("%w: %w", errNoWorkDir, err)} - } - read := func(name string) ([]byte, error) { return plan.ReadFile(ctx, name) } - refusals := acp.Refusals(acp.Preflight(a, plan.Dir, nil, read)) - for i, refusal := range refusals { - // A layer nothing read is said as that, not as a refusal over what - // is in it: doctor does not model a link or a submodule in a - // planned worktree, and must not let one pass as checked either. - var unmodeled *connector.UnmodeledPathError - if errors.As(refusal, &unmodeled) { - refusals[i] = unmodeledLayerError{ - err: refusal, - msg: "a configuration layer this could not check: " + - strings.ReplaceAll(unmodeled.Error(), plan.Worktree, plan.Repository), - } - continue - } - // A file inside the planned worktree is the repository's, at the - // commit the worktree would be made from: say where a person can go - // and change it, not where a directory nobody has made yet would - // have held it. The error keeps its chain, so what it is stays the - // same as what it says. - if named := strings.ReplaceAll(refusal.Error(), plan.Worktree, plan.Repository); named != refusal.Error() { - refusals[i] = inRepositoryError{err: refusal, msg: named + " (committed in the repository, so every worktree of it has the file)"} - } - } - return refusals -} - -// errNoWorkDir is a route no task could be given a working directory in. -var errNoWorkDir = errors.New("no task on it would get a working directory") - -// inRepositoryError is a refusal over a file in a planned worktree, said as the -// repository's file. It is the same error: only its words move. -type inRepositoryError struct { - err error - msg string -} - -func (e inRepositoryError) Error() string { return e.msg } -func (e inRepositoryError) Unwrap() error { return e.err } - -// unmodeledLayerError is a configuration layer doctor did not read, -// because a planned worktree's links and submodules are not modeled. It is -// the same error: only its words move. -type unmodeledLayerError struct { - err error - msg string -} - -func (e unmodeledLayerError) Error() string { return e.msg } -func (e unmodeledLayerError) Unwrap() error { return e.err } - // preflightReason is a preflight's refusal as a person reads it: the driver // package's own prefix off the front, because the check already names the // adapter, and the rest as it is — it names the file, which is the whole diff --git a/internal/commands/connect_operator.go b/internal/commands/connect_operator.go index e5f950fb9..b5fa30277 100644 --- a/internal/commands/connect_operator.go +++ b/internal/commands/connect_operator.go @@ -166,7 +166,7 @@ func newConnectStatusCmd() *cobra.Command { Short: "Show what the connector heard, holds and ran", Long: `Show the connector's ledger: what its instance lock file says, the hold, the feed position (whether one is held, never the position), the last poll-served id, -gaps and losses, queue depths, live tasks, retained worktrees, lifecycle +gaps and losses, queue depths, live tasks, lifecycle messages waiting for a person, held records, and the last 20 dispatches with their outcomes. @@ -225,7 +225,7 @@ func runConnectStatus(cmd *cobra.Command, shadow bool) error { return err } defer func() { _ = ledger.Close() }() - status, err := ledger.Status(cmd.Context(), ledger.RetainedWorktreeStatus) + status, err := ledger.Status(cmd.Context()) if err != nil { return err } @@ -244,15 +244,6 @@ func runConnectStatus(cmd *cobra.Command, shadow bool) error { return p.app.OK(report, output.WithSummary(connectStatusSummary(report))) } -// worktreesUnavailable is why status cannot say whether any worktrees are -// retained. It never reads as none. -func worktreesUnavailable(s connector.Status) string { - if s.WorktreesUnavailable != "" { - return s.WorktreesUnavailable - } - return "nothing listed them for this status" -} - func connectStatusSummary(r connectStatusReport) string { parts := []string{} if r.Status.Hold != nil { @@ -339,14 +330,6 @@ func renderConnectStatus(w io.Writer, r connectStatusReport) { fmt.Fprintf(w, " task %d %s %s pid %d (%s) token taker pid %d (%s) since %s events %v in %s\n", t.TaskID, clean(t.AttemptID), clean(t.State), t.PID, clean(t.Worker), t.TakerPID, clean(t.Taker), stamp(t.LaunchedAt), t.EventIDs, clean(t.WorkDir)) } - if !s.WorktreesKnown { - fmt.Fprintf(w, " Worktrees unavailable: %s\n", clean(worktreesUnavailable(s))) - } else { - fmt.Fprintf(w, " Worktrees %d retained\n", len(s.Worktrees)) - for _, wt := range s.Worktrees { - fmt.Fprintf(w, " %s %s\n", clean(wt.Path), clean(wt.Reason)) - } - } fmt.Fprintf(w, " Indeterminate %d lifecycle messages wait for a person\n", len(s.Indeterminate)) for _, in := range s.Indeterminate { fmt.Fprintf(w, " intent %d %s event %d %s on %d\n", in.ID, clean(in.Kind), in.EventID, clean(in.MessageKind), in.RecordingID) diff --git a/internal/commands/connect_operator_test.go b/internal/commands/connect_operator_test.go index d71ce5dd3..0bb4d6312 100644 --- a/internal/commands/connect_operator_test.go +++ b/internal/commands/connect_operator_test.go @@ -238,8 +238,8 @@ func TestConnectHoldFlagIsOnTheRunCommand(t *testing.T) { } // doctor refuses what the run command refuses, and nothing the run command -// runs. Both the acp driver and worktrees are on the run command now, so -// neither is a failing check any more. +// runs. The acp driver is on the run command now, so it is not a failing +// check any more. func TestConnectDoctorRefusesOnlyWhatTheRunCommandRefuses(t *testing.T) { file := setup.New("agent") assert.Empty(t, driverChecks(connectProfile{name: "agent", file: file})) @@ -249,11 +249,6 @@ func TestConnectDoctorRefusesOnlyWhatTheRunCommandRefuses(t *testing.T) { assert.Empty(t, driverChecks(connectProfile{name: "agent", file: acpFile}), "the acp driver is a driver the connector starts on") - worktrees := setup.New("agent") - worktrees.Worktrees = true - assert.Empty(t, driverChecks(connectProfile{name: "agent", file: worktrees}), - "the connector starts with worktrees on") - unknown := setup.New("agent") unknown.Driver = "someday" checks := driverChecks(connectProfile{name: "agent", file: unknown}) @@ -300,7 +295,7 @@ func TestConnectDoctorFindsTheACPAdapterWhereTheDriverDoes(t *testing.T) { file := setup.New("agent") file.Driver = setup.DriverACP - checks := workerBinaryChecks(context.Background(), file) + checks := workerBinaryChecks(file) require.Len(t, checks, 1) assert.Equal(t, setup.StatusFail, checks[0].Status, "an unpinned executable that happens to be on PATH is not the pinned adapter") @@ -319,7 +314,7 @@ func TestConnectDoctorFindsTheACPAdapterWhereTheDriverDoes(t *testing.T) { bin := filepath.Join(binDir, adapter.Name) require.NoError(t, os.WriteFile(bin, []byte("#!/bin/sh\nexit 0\n"), 0o700)) - checks = workerBinaryChecks(context.Background(), file) + checks = workerBinaryChecks(file) require.Len(t, checks, 1) assert.Equal(t, setup.StatusPass, checks[0].Status, "the adapter make acp-adapters installed is the one doctor finds") assert.Contains(t, checks[0].Message, bin) @@ -328,7 +323,7 @@ func TestConnectDoctorFindsTheACPAdapterWhereTheDriverDoes(t *testing.T) { // A version other than the pin is not the adapter the driver would run. require.NoError(t, os.WriteFile(filepath.Join(pkgDir, "package.json"), []byte(fmt.Sprintf(`{"name":%q,"version":"0.0.1-not-the-pin"}`, adapter.Package)), 0o600)) - checks = workerBinaryChecks(context.Background(), file) + checks = workerBinaryChecks(file) require.Len(t, checks, 1) assert.Equal(t, setup.StatusFail, checks[0].Status, "an adapter off the pin is not ready") } @@ -353,31 +348,6 @@ func TestConnectDoctorReportsLedgerGapsAndTheHold(t *testing.T) { assert.Equal(t, setup.StatusWarn, byName["Hold"].Status) } -// doctor reads card 19's worktree ledger too: worktrees the connector kept -// are a person's to deal with, and doctor is where a person finds out. -func TestConnectDoctorReportsTheWorktreesTheConnectorKept(t *testing.T) { - ctx := context.Background() - f := newOperatorFixture(t) - l := f.ledger(t, false) - id, err := l.BeginWorktree(ctx, connector.Worktree{ - Path: "/w/one", WorkDir: "/w/one/app", Route: "app", - Repository: "/repo", Branch: "basecamp-connect/1-a1b2c3", BaseCommit: "abc", - }) - require.NoError(t, err) - require.NoError(t, l.MoveWorktree(ctx, id, connector.WorktreeLive, connector.WorktreeCreating)) - require.NoError(t, l.RetainWorktree(ctx, id, connector.RetainedDirty, connector.WorktreeLive)) - require.NoError(t, l.Close()) - - byName := map[string]setup.Check{} - for _, c := range ledgerChecks(ctx, connectProfile{name: "agent", file: f.file}) { - byName[c.Name] = c - } - require.Contains(t, byName, "Worktrees") - assert.Equal(t, setup.StatusWarn, byName["Worktrees"].Status) - assert.Contains(t, byName["Worktrees"].Message, "1 worktree(s) kept") - assert.Contains(t, byName["Worktrees"].Hint, "basecamp connect worktrees list") -} - // fakeMCPServerArg marks a test binary run as the doctor's MCP server. const fakeMCPServerArg = "fake-basecamp-mcp" @@ -554,45 +524,6 @@ func TestTheDecisionCommandsSpeakSnakeCase(t *testing.T) { assert.NotContains(t, out, `"StillHeld"`) } -// Status reads card 19's worktree ledger: the worktrees the connector kept -// are what it reports, with why each is kept. -func TestStatusReportsTheWorktreesTheConnectorKept(t *testing.T) { - ctx := context.Background() - f := newOperatorFixture(t) - l := f.ledger(t, false) - id, err := l.BeginWorktree(ctx, connector.Worktree{ - Path: "/w/one", WorkDir: "/w/one/app", Route: "app", - Repository: "/repo", Branch: "basecamp-connect/1-a1b2c3", BaseCommit: "abc", - }) - require.NoError(t, err) - require.NoError(t, l.MoveWorktree(ctx, id, connector.WorktreeLive, connector.WorktreeCreating)) - require.NoError(t, l.RetainWorktree(ctx, id, connector.RetainedDirty, connector.WorktreeLive)) - require.NoError(t, l.Close()) - - styled, err := f.run(t, output.FormatStyled, "status") - require.NoError(t, err, styled) - assert.Contains(t, styled, "Worktrees 1 retained") - assert.Contains(t, styled, "/w/one dirty") - assert.NotContains(t, styled, "Worktrees unavailable") - - out, err := f.run(t, output.FormatJSON, "status") - require.NoError(t, err, out) - assert.Contains(t, out, `"worktrees_known": true`) - assert.Contains(t, out, `"reason": "dirty"`) -} - -// A listing that could not be read is unavailable, never none: the -// distinction the nil lister carried is now what a failed listing carries. -func TestStatusSaysWorktreesAreUnavailableNotNone(t *testing.T) { - var buf bytes.Buffer - renderConnectStatus(&buf, connectStatusReport{Profile: "agent", Status: connector.Status{ - Queues: map[string]int{}, Blocked: map[string]int{}, - WorktreesUnavailable: "the worktrees table cannot be read", - }}) - assert.Contains(t, buf.String(), "Worktrees unavailable: the worktrees table cannot be read") - assert.NotContains(t, buf.String(), "0 retained") -} - // preflightCheck is the row acpPreflightCheck adds to doctor's worker // checks, found by the name a person reads rather than by position. func preflightCheck(t *testing.T, checks []setup.Check) (setup.Check, bool) { @@ -643,7 +574,7 @@ func writeCodexConfig(t *testing.T, dir, body string) string { func TestConnectDoctorRunsTheAdaptersPreflightAgainstEveryRoutedDirectory(t *testing.T) { file, home, _ := codexProfile(t, 2) - checks := workerBinaryChecks(context.Background(), file) + checks := workerBinaryChecks(file) c, ok := preflightCheck(t, checks) require.True(t, ok, "the codex adapter's preflight is a check of its own") assert.Equal(t, setup.StatusPass, c.Status) @@ -653,7 +584,7 @@ func TestConnectDoctorRunsTheAdaptersPreflightAgainstEveryRoutedDirectory(t *tes // uses Codex with MCP servers at all has. userConfig := writeCodexConfig(t, home, "[mcp_servers.linear]\ncommand = \"linear-mcp\"\n") - c, ok = preflightCheck(t, workerBinaryChecks(context.Background(), file)) + c, ok = preflightCheck(t, workerBinaryChecks(file)) require.True(t, ok) assert.Equal(t, setup.StatusFail, c.Status, "doctor never calls a profile ready that would not start") assert.Contains(t, c.Message, userConfig, "the person reading this has to know which file") @@ -676,7 +607,7 @@ func TestConnectDoctorPreflightNamesEveryRouteThatWouldNotStart(t *testing.T) { writeCodexConfig(t, paths[1], "[mcp_servers.linear]\ncommand = \"linear-mcp\"\n") writeCodexConfig(t, paths[2], "[mcp_servers.other]\ncommand = \"other-mcp\"\n") - c, ok := preflightCheck(t, workerBinaryChecks(context.Background(), file)) + c, ok := preflightCheck(t, workerBinaryChecks(file)) require.True(t, ok) assert.Equal(t, setup.StatusFail, c.Status) assert.Contains(t, c.Message, filepath.Join(paths[1], ".codex", "config.toml")) @@ -691,7 +622,7 @@ func TestConnectDoctorPreflightNamesEveryRouteThatWouldNotStart(t *testing.T) { // never looked at. func TestConnectDoctorPreflightSkipsWithNoRoute(t *testing.T) { file, _, _ := codexProfile(t, 0) - c, ok := preflightCheck(t, workerBinaryChecks(context.Background(), file)) + c, ok := preflightCheck(t, workerBinaryChecks(file)) require.True(t, ok) assert.Equal(t, setup.StatusSkip, c.Status) assert.Contains(t, c.Message, "No project is routed") @@ -705,7 +636,7 @@ func TestConnectDoctorHasNoPreflightRowForAnAdapterWithoutOne(t *testing.T) { file.Driver = setup.DriverACP file.Worker = setup.WorkerClaude file.Projects[1] = admission.Route{Path: t.TempDir()} - _, ok := preflightCheck(t, workerBinaryChecks(context.Background(), file)) + _, ok := preflightCheck(t, workerBinaryChecks(file)) assert.False(t, ok) } @@ -718,7 +649,7 @@ func TestConnectDoctorHasNoPreflightRowUnderTheSpawnDriver(t *testing.T) { file.Driver = setup.DriverSpawn file.Worker = setup.WorkerCodex file.Projects[1] = admission.Route{Path: t.TempDir()} - _, ok := preflightCheck(t, workerBinaryChecks(context.Background(), file)) + _, ok := preflightCheck(t, workerBinaryChecks(file)) assert.False(t, ok) } @@ -729,124 +660,13 @@ func TestConnectDoctorPreflightNamesTheOnlyRoute(t *testing.T) { file, home, paths := codexProfile(t, 1) writeCodexConfig(t, home, "[mcp_servers.linear]\ncommand = \"linear-mcp\"\n") - c, ok := preflightCheck(t, workerBinaryChecks(context.Background(), file)) + c, ok := preflightCheck(t, workerBinaryChecks(file)) require.True(t, ok) assert.Equal(t, setup.StatusFail, c.Status) assert.Contains(t, c.Message, paths[0]) assert.NotContains(t, c.Message, "any routed directory") } -// codexWorktreeProfile is an acp/codex profile with worktrees on and one -// route inside a git repository, with its own HOME and state home: the -// machine state the Codex preflight reads, and nothing of the person -// running the test. It answers the route, the repository and the connector -// state directory the worktrees would go under. -func codexWorktreeProfile(t *testing.T) (setup.File, string, string, string) { - t.Helper() - if _, err := exec.LookPath("git"); err != nil { - t.Skip("git is not installed") - } - home := t.TempDir() - state := t.TempDir() - t.Setenv("HOME", home) - t.Setenv("XDG_STATE_HOME", state) - t.Setenv("CODEX_HOME", "") - - repo := filepath.Join(t.TempDir(), "repo") - route := filepath.Join(repo, "app") - require.NoError(t, os.MkdirAll(route, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(route, "README"), []byte("hi\n"), 0o600)) - testGit(t, repo, home, "init", "-q", "-b", "main") - testGit(t, repo, home, "add", ".") - testGit(t, repo, home, "commit", "-q", "-m", "init") - - file := setup.New("agent") - file.Driver = setup.DriverACP - file.Worker = setup.WorkerCodex - file.Worktrees = true - file.Projects[1] = admission.Route{Path: route} - - stateDir, err := connectStatePath(file, false) - require.NoError(t, err) - return file, route, repo, stateDir -} - -func testGit(t *testing.T, dir, home string, args ...string) { - t.Helper() - cmd := exec.CommandContext(context.Background(), "git", - append([]string{"-c", "user.name=Test", "-c", "user.email=test@example.invalid", "-c", "commit.gpgsign=false"}, args...)...) - cmd.Dir = dir - cmd.Env = []string{"HOME=" + home, "PATH=" + os.Getenv("PATH"), "GIT_CONFIG_NOSYSTEM=1"} - out, err := cmd.CombinedOutput() - require.NoError(t, err, "git %v: %s", args, out) -} - -// With worktrees on the session never runs in the route: it runs in a -// worktree under the connector's state directory. A Codex config layer the -// route has but the repository does not track is in no worktree, so it -// blocks nothing and doctor must not refuse the profile over it. -func TestConnectDoctorPreflightDoesNotRefuseARouteFileNoWorktreeWouldHave(t *testing.T) { - file, route, _, _ := codexWorktreeProfile(t) - writeCodexConfig(t, route, "[mcp_servers.linear]\ncommand = \"linear-mcp\"\n") - - c, ok := preflightCheck(t, workerBinaryChecks(context.Background(), file)) - require.True(t, ok) - assert.Equal(t, setup.StatusPass, c.Status, - "an untracked config in the route is in no worktree, so no dispatch reads it") -} - -// And a layer above the worktrees — under the connector's state directory, -// which is above every worktree and above no route — blocks every dispatch. -// Checking the route would miss it, which is this card's bug again. -func TestConnectDoctorPreflightReadsTheLayersAboveTheWorktree(t *testing.T) { - file, _, _, stateDir := codexWorktreeProfile(t) - require.NoError(t, os.MkdirAll(stateDir, 0o700)) - config := writeCodexConfig(t, stateDir, "[mcp_servers.linear]\ncommand = \"linear-mcp\"\n") - - c, ok := preflightCheck(t, workerBinaryChecks(context.Background(), file)) - require.True(t, ok) - assert.Equal(t, setup.StatusFail, c.Status, "a layer above every worktree blocks every dispatch") - assert.Contains(t, c.Message, config) -} - -// A config the repository tracks is in every worktree made from it, so it -// does block every dispatch — and the message names the file where a person -// can change it, not a worktree nobody has made. -func TestConnectDoctorPreflightNamesTheRepositoryForACommittedConfig(t *testing.T) { - file, route, repo, stateDir := codexWorktreeProfile(t) - committed := writeCodexConfig(t, route, "[mcp_servers.linear]\ncommand = \"linear-mcp\"\n") - testGit(t, repo, os.Getenv("HOME"), "add", ".") - testGit(t, repo, os.Getenv("HOME"), "commit", "-q", "-m", "codex config") - - c, ok := preflightCheck(t, workerBinaryChecks(context.Background(), file)) - require.True(t, ok) - assert.Equal(t, setup.StatusFail, c.Status) - assert.Contains(t, c.Message, committed, "the file a person can go and change") - assert.Contains(t, c.Message, "committed in the repository") - assert.NotContains(t, c.Message, stateDir, "not a path inside a worktree that does not exist") - assert.Contains(t, c.Hint, "CODEX_HOME", "renaming the file in the message does not lose what the refusal is") -} - -// With worktrees on, a route that can take no worktree takes no task: every -// dispatch on it waits in a backoff nothing reports. Doctor says so instead -// of calling the profile ready. -func TestConnectDoctorPreflightRefusesARouteThatCouldTakeNoWorktree(t *testing.T) { - file, route, _, _ := codexWorktreeProfile(t) - outside := filepath.Join(t.TempDir(), "not-a-repo") - require.NoError(t, os.MkdirAll(outside, 0o755)) - file.Projects[2] = admission.Route{Path: outside} - - c, ok := preflightCheck(t, workerBinaryChecks(context.Background(), file)) - require.True(t, ok) - assert.Equal(t, setup.StatusFail, c.Status) - assert.Contains(t, c.Message, outside) - assert.Contains(t, c.Message, "would get a working directory") - assert.NotContains(t, c.Message, route, "the route that would run is not named") - assert.Contains(t, c.Hint, "without worktrees", "a route that cannot take a worktree is not fixed by editing a Codex config") - assert.Contains(t, c.Message, "no worktree can be made on this route", - "and it is the route itself, proved, not a read that failed this once") -} - // A config layer that cannot be read refuses every session too — nothing // can say it declares no MCP server — but it is not a declaration, and // telling a person to take mcp_servers out of a file they cannot read is @@ -860,7 +680,7 @@ func TestConnectDoctorPreflightSaysWhatToDoAboutAnUnreadableConfig(t *testing.T) require.NoError(t, os.Chmod(config, 0o000)) t.Cleanup(func() { _ = os.Chmod(config, 0o600) }) - c, ok := preflightCheck(t, workerBinaryChecks(context.Background(), file)) + c, ok := preflightCheck(t, workerBinaryChecks(file)) require.True(t, ok) assert.Equal(t, setup.StatusFail, c.Status, "what cannot be read cannot be vouched for") assert.Contains(t, c.Message, config) @@ -881,52 +701,13 @@ func TestConnectDoctorPreflightNamesEveryLayerInOneRun(t *testing.T) { user := writeCodexConfig(t, home, "[mcp_servers.linear]\ncommand = \"linear-mcp\"\n") project := writeCodexConfig(t, paths[0], "[mcp_servers.other]\ncommand = \"other-mcp\"\n") - c, ok := preflightCheck(t, workerBinaryChecks(context.Background(), file)) + c, ok := preflightCheck(t, workerBinaryChecks(file)) require.True(t, ok) assert.Equal(t, setup.StatusFail, c.Status) assert.Contains(t, c.Message, user) assert.Contains(t, c.Message, project, "the route's own layer is named in the same run as the shared one") } -// A Codex config that is a symbolic link is a layer doctor does not model: -// git hands back the link's target text where the session would read the -// file it points at. What it must not do is read that text, find no -// mcp_servers in it, and call the profile ready — a layer nothing read is -// said as one, and the profile is not ready on the strength of it. -func TestConnectDoctorPreflightSaysWhenALayerCouldNotBeChecked(t *testing.T) { - file, route, repo, _ := codexWorktreeProfile(t) - require.NoError(t, os.WriteFile(filepath.Join(repo, "codex.toml"), []byte("[mcp_servers.linear]\ncommand = \"linear-mcp\"\n"), 0o600)) - require.NoError(t, os.MkdirAll(filepath.Join(route, ".codex"), 0o755)) - require.NoError(t, os.Symlink("../../codex.toml", filepath.Join(route, ".codex", "config.toml"))) - testGit(t, repo, os.Getenv("HOME"), "add", ".") - testGit(t, repo, os.Getenv("HOME"), "commit", "-q", "-m", "a linked codex config") - - c, ok := preflightCheck(t, workerBinaryChecks(context.Background(), file)) - require.True(t, ok) - assert.Equal(t, setup.StatusFail, c.Status, "a layer nothing read is not a layer that passed") - assert.Contains(t, c.Message, "could not check") - assert.Contains(t, c.Message, filepath.Join(route, ".codex", "config.toml"), "the layer is named where a person can look at it") - assert.Contains(t, c.Message, "a symbolic link", "and what is in the way is said") - assert.Contains(t, c.Hint, "yourself") - assert.Contains(t, c.Hint, "without worktrees") -} - -// A route git could read as an option or a pattern is a route like any -// other: doctor must not report it as a configuration it cannot read. -func TestConnectDoctorPreflightReadsARouteNamedLikeAnOption(t *testing.T) { - file, _, repo, _ := codexWorktreeProfile(t) - dashed := filepath.Join(repo, "-app") - require.NoError(t, os.MkdirAll(dashed, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(dashed, "README"), []byte("hi\n"), 0o600)) - testGit(t, repo, os.Getenv("HOME"), "add", ".") - testGit(t, repo, os.Getenv("HOME"), "commit", "-q", "-m", "a directory named like an option") - file.Projects[2] = admission.Route{Path: dashed} - - c, ok := preflightCheck(t, workerBinaryChecks(context.Background(), file)) - require.True(t, ok) - assert.Equal(t, setup.StatusPass, c.Status, "nothing in that repository refuses a session") -} - // A layer every route shares is reported once for all of them even when one // route has a second reason of its own: they are grouped one refusal at a // time, not by the whole of what a route was refused for. @@ -935,7 +716,7 @@ func TestConnectDoctorPreflightGroupsEachRefusalOnItsOwn(t *testing.T) { user := writeCodexConfig(t, home, "[mcp_servers.linear]\ncommand = \"linear-mcp\"\n") project := writeCodexConfig(t, paths[1], "[mcp_servers.other]\ncommand = \"other-mcp\"\n") - c, ok := preflightCheck(t, workerBinaryChecks(context.Background(), file)) + c, ok := preflightCheck(t, workerBinaryChecks(file)) require.True(t, ok) assert.Equal(t, setup.StatusFail, c.Status) assert.Equal(t, 1, strings.Count(c.Message, user), "the shared layer is named once") @@ -962,7 +743,7 @@ func TestConnectDoctorPreflightHintsAtWhatMostNeedsDoing(t *testing.T) { require.NoError(t, os.Chmod(unreadable, 0o000)) t.Cleanup(func() { _ = os.Chmod(unreadable, 0o600) }) - c, ok := preflightCheck(t, workerBinaryChecks(context.Background(), file)) + c, ok := preflightCheck(t, workerBinaryChecks(file)) require.True(t, ok) assert.Equal(t, setup.StatusFail, c.Status) assert.Contains(t, c.Message, declared, "both layers are reported") @@ -973,29 +754,3 @@ func TestConnectDoctorPreflightHintsAtWhatMostNeedsDoing(t *testing.T) { assert.Less(t, strings.Index(c.Hint, "Take the MCP servers out"), strings.Index(c.Hint, "readable by the user"), "most pressing first, not first in the order the layers are read") } - -// A route the repository does not track is a directory no worktree would -// hold, so a session there would have nowhere to start. Doctor says so -// rather than passing over it — the route exists on disk, and every check -// that only looks at the disk is satisfied by it. -func TestConnectDoctorPreflightRefusesARouteNoWorktreeWouldHold(t *testing.T) { - file, route, repo, _ := codexWorktreeProfile(t) - - // The tracked route the repository has is ready, and stays ready. - c, ok := preflightCheck(t, workerBinaryChecks(context.Background(), file)) - require.True(t, ok) - require.Equal(t, setup.StatusPass, c.Status, "a route the repository tracks is in every worktree of it") - assert.Contains(t, c.Message, "would start") - - untracked := filepath.Join(repo, "scratch") - require.NoError(t, os.MkdirAll(untracked, 0o755)) - file.Projects[2] = admission.Route{Path: untracked} - - c, ok = preflightCheck(t, workerBinaryChecks(context.Background(), file)) - require.True(t, ok) - assert.Equal(t, setup.StatusFail, c.Status, "doctor never calls a connector ready that would not start") - assert.Contains(t, c.Message, untracked) - assert.Contains(t, c.Message, "no worktree of it would hold the route") - assert.Contains(t, c.Hint, "Commit it", "and what to do about it") - assert.NotContains(t, c.Message, "start in "+route, "the route that would run is not named") -} diff --git a/internal/commands/connect_run.go b/internal/commands/connect_run.go index 22eb16f48..8d418735b 100644 --- a/internal/commands/connect_run.go +++ b/internal/commands/connect_run.go @@ -87,7 +87,7 @@ func ensurePrivateChain(root string, parts ...string) (string, error) { // connectStateDir is the connector's state directory for a set-up profile, // created owner-only: $XDG_STATE_HOME/basecamp/connect/-, or // connect-shadow for a shadow run. Everything that reads the connector's -// state (worktrees prune, status) resolves it here. +// state (status) resolves it here. func connectStateDir(file setup.File, shadow bool) (string, error) { stateHome, err := connectStateHome() if err != nil { @@ -352,19 +352,6 @@ func runConnect(cmd *cobra.Command, f *connectRunFlags) error { if err != nil { return output.ErrUsage(err.Error()) } - // Built with worktrees off too, so the ones made while they were on - // are still settled and recovered. - worktreesRoot, err := ensurePrivateChain(stateDir, connectWorktreesDir) - if err != nil { - return err - } - workspaces, err := connector.NewWorktrees(connector.WorktreesOptions{ - Ledger: ledger, Root: worktreesRoot, Logger: logger, Off: !file.Worktrees, - Redaction: driver.Redaction{Dirs: []string{stateDir}}, - }) - if err != nil { - return err - } options := connectDispatcherOptions(connectDispatch{ File: file, Buckets: buckets, Ledger: ledger, Driver: worker, Routes: routes.Current, Profile: name, Executable: exe, StateDir: stateDir, SessionsDir: sessions, @@ -376,7 +363,6 @@ func runConnect(cmd *cobra.Command, f *connectRunFlags) error { Replies: connector.LifecycleFilteredReplies{Lister: poster, Ledger: ledger}, Lines: lines, Logger: logger, }) - options.Workspaces = workspaces dispatcher, err = connector.NewDispatcher(options) if err != nil { return err diff --git a/internal/commands/connect_worktrees.go b/internal/commands/connect_worktrees.go deleted file mode 100644 index 06e4d8ce5..000000000 --- a/internal/commands/connect_worktrees.go +++ /dev/null @@ -1,332 +0,0 @@ -package commands - -import ( - "context" - "errors" - "fmt" - "log/slog" - "os" - "path/filepath" - "time" - - "github.com/spf13/cobra" - - "github.com/basecamp/basecamp-cli/internal/appctx" - "github.com/basecamp/basecamp-cli/internal/config" - "github.com/basecamp/basecamp-cli/internal/connector" - "github.com/basecamp/basecamp-cli/internal/connector/setup" - "github.com/basecamp/basecamp-cli/internal/output" -) - -// connectWorktreesDir is where a connector's task worktrees live, under its -// state directory. -const connectWorktreesDir = "worktrees" - -func newConnectWorktreesCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "worktrees", - Short: "List and prune the git worktrees the connector kept", - Long: `With worktrees on (connect setup --worktrees), each task works in a git -worktree of its own, on a basecamp-connect/ branch. The connector never -removes one: when the task ends its worktree is kept and listed here, with -the task it was for and what it takes up on disk. You remove them with prune, -which goes by what could be lost — nothing on the disk but the files git -tracks, unchanged, no merge or rebase in progress, not locked, and every -commit it reaches held elsewhere — and keeps what could. - -They add up: every task leaves one, so prune is part of running a connector -with worktrees on. A Codex worker cannot commit — a worktree's git data is -outside the directory its sandbox may write — so with Codex every task that -edits anything leaves a worktree with work in it.`, - } - cmd.AddCommand(newConnectWorktreesListCmd(), newConnectWorktreesPruneCmd()) - return cmd -} - -func newConnectWorktreesListCmd() *cobra.Command { - var shadow bool - cmd := &cobra.Command{ - Use: "list", - Short: "List the worktrees kept for you to deal with", - Long: `List the worktrees the connector kept, with the task each was for, its size -on disk, git's record of it, and why it is kept: finished (its task ended — -the connector removes no worktree of its own accord), dirty (uncommitted -work), unpushed (commits nothing else holds), locked, moved (no longer where -the connector left it), orphaned (its directory is gone, while git's record -of it and the task branch are still there), or unverified (their state could -not be read). A prune says which of these a worktree turns out to be.`, - Example: ` basecamp connect worktrees list -P agent`, - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, _ []string) error { - app := appctx.FromContext(cmd.Context()) - wt, closeLedger, err := openConnectWorktrees(app, shadow) - if err != nil { - return err - } - defer closeLedger() - retained, err := wt.Retained(cmd.Context()) - if err != nil { - return err - } - out := make([]worktreeView, 0, len(retained)) - for _, r := range retained { - out = append(out, viewWorktree(r)) - } - return app.OK(out, output.WithSummary(fmt.Sprintf("%d worktree(s) kept", len(out)))) - }, - } - cmd.Flags().BoolVar(&shadow, "shadow", false, "Read the shadow connector's state instead") - return cmd -} - -func newConnectWorktreesPruneCmd() *cobra.Command { - var ( - force []string - shadow bool - ) - cmd := &cobra.Command{ - Use: "prune", - Short: "Remove the kept worktrees you have dealt with", - Long: `Remove every kept worktree that holds no work: clean, with every commit it -reaches held elsewhere. This is the only thing that removes a worktree. One -that still holds work is kept and listed with why. - ---force removes that worktree even with work in it; name each one, and -it tells you what goes. -Every commit it reaches that nothing else holds is first kept under -refs/basecamp-connect/retained/ (retained_refs), so a force on a worktree that -is still on disk discards files, never commits. A worktree holding a submodule's own git data, or a lock, is -never forced; neither is one that is no longer where it was (reason "moved"): -move it back, or remove it yourself and prune again. A force that could not go -through is reported as kept with force_refused. Worktrees of tasks still -running are never touched. - -A worktree whose directory something else removed (reason "orphaned") is left -exactly as it is — git's record of it and the task branch, whatever they reach -— and a plain prune leaves it alone. A force on its path deletes the task -branch and nothing else, leaving git's record for ` + "`git worktree prune`" + `: -commits only that branch or that record reached go when you do that, and -nothing here works out which those are. Move the directory back, or keep the -branch, if you want them. A worktree whose state could not be read -(reason "unverified") is kept; forcing it keeps every commit that could be -found, which in a repository that keeps no reflogs may not be all of them.`, - Example: ` basecamp connect worktrees prune -P agent - basecamp connect worktrees prune -P agent --force ~/.local/state/basecamp/connect/2914079-52007412/worktrees/app-1a2b3c4d/17-a1b2c3`, - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, _ []string) error { - app := appctx.FromContext(cmd.Context()) - for i, p := range force { - if !filepath.IsAbs(p) { - return output.ErrUsage(fmt.Sprintf("--force %q: name the worktree by its absolute path, as worktrees list shows it", p)) - } - force[i] = filepath.Clean(p) - } - wt, closeLedger, err := openConnectWorktrees(app, shadow) - if err != nil { - return err - } - defer closeLedger() - results, err := wt.Prune(cmd.Context(), force) - if errors.Is(err, connector.ErrNotRetained) { - return output.ErrUsageHint("Nothing was pruned: "+err.Error(), "--force takes a path from `basecamp connect worktrees list`.") - } - if err != nil { - return err - } - out := make([]pruneView, 0, len(results)) - removed, kept := 0, 0 - for _, r := range results { - out = append(out, pruneView{ - worktreeView: viewWorktree(r.Worktree), Action: string(r.Action), ForceRefused: r.ForceRefused, - RetainedRefs: r.RetainedRefs, BranchDeletedAt: r.BranchDeletedAt, - }) - if r.Action == connector.PruneKept { - kept++ - } else { - removed++ - } - } - return app.OK(out, output.WithSummary(fmt.Sprintf("%d removed, %d kept", removed, kept))) - }, - } - cmd.Flags().StringArrayVar(&force, "force", nil, "Remove this kept worktree even with work in it (repeatable; an absolute path from worktrees list)") - cmd.Flags().BoolVar(&shadow, "shadow", false, "Read the shadow connector's state instead") - return cmd -} - -// worktreeView is a kept worktree as the commands show it. -type worktreeView struct { - Path string `json:"path"` - State string `json:"state"` - // SizeBytes is what the worktree takes up on disk, so an operator can - // see what reclaiming it is worth; -1 when it is there and could not be - // read, and nothing at all for one that is gone. - SizeBytes int64 `json:"size_bytes,omitempty"` - WorkDir string `json:"work_dir"` - Branch string `json:"branch"` - Route string `json:"route"` - Reason string `json:"reason,omitempty"` - // Record is git's record of the worktree (/.git/worktrees/), - // which outlives a directory something else removed: what an operator - // needs to find what is left, and what `git worktree prune` clears. - Record string `json:"record,omitempty"` - EventID int64 `json:"event_id"` - TaskID int64 `json:"task_id,omitempty"` - RetainedAt string `json:"retained_at,omitempty"` -} - -type pruneView struct { - worktreeView - Action string `json:"action"` - ForceRefused bool `json:"force_refused,omitempty"` - RetainedRefs []string `json:"retained_refs,omitempty"` - // BranchDeletedAt is where the task branch stood when a force on an - // orphaned worktree deleted it: nothing worked out what it reached, so - // this is what puts it back (git branch ). - BranchDeletedAt string `json:"branch_deleted_at,omitempty"` -} - -// sizeLimit bounds how long reading a worktree's size may take: a listing is -// not worth holding for a tree that cannot be walked. -const sizeLimit = 5 * time.Second - -// reasonOf is why a worktree is kept: nothing, for one that is not. -func reasonOf(w connector.Worktree) string { - if w.State == connector.WorktreeRemoved { - return "" - } - return string(w.RetainedReason) -} - -// recordOf is git's record of the worktree, when it is still there: the -// directory an orphaned worktree leaves behind. -func recordOf(w connector.Worktree) string { - if w.AdminDir == "" || w.State == connector.WorktreeRemoved { - return "" - } - if _, err := os.Lstat(w.AdminDir); err != nil { - return "" - } - return w.AdminDir -} - -// sizeOf is what a worktree takes up on disk. A worktree that is not there -// takes up nothing, and is not walked for an answer; one a removal has -// frozen is under its removing name. -func sizeOf(w connector.Worktree) int64 { - for _, path := range []string{w.Path, w.Path + connector.RemovingSuffix} { - switch _, err := os.Lstat(path); { - case err == nil: - return dirSize(path) - case !errors.Is(err, os.ErrNotExist): - return -1 - } - } - return 0 -} - -// dirSize is what a directory takes up, in bytes, following no symlink; -1 -// when it cannot be read in time or at all. The walk runs apart from the -// answer: a filesystem call that never returns — a mount a worker left — -// keeps only its own goroutine, and never the listing. -func dirSize(path string) int64 { - deadline := time.Now().Add(sizeLimit) - walked := make(chan int64, 1) - go func() { - var total int64 - err := filepath.WalkDir(path, func(_ string, d os.DirEntry, err error) error { - if err != nil { - return err - } - if time.Now().After(deadline) { - return errors.New("the worktree could not be read in time") - } - if d.IsDir() { - return nil - } - info, err := d.Info() - if err != nil { - return err - } - if info.Mode().IsRegular() { - total += info.Size() - } - return nil - }) - if err != nil { - total = -1 - } - walked <- total - }() - timer := time.NewTimer(time.Until(deadline)) - defer timer.Stop() - select { - case total := <-walked: - return total - case <-timer.C: - return -1 - } -} - -func viewWorktree(w connector.Worktree) worktreeView { - v := worktreeView{ - Path: w.Path, State: string(w.State), SizeBytes: sizeOf(w), WorkDir: w.WorkDir, - Branch: w.Branch, Route: w.Route, Reason: reasonOf(w), Record: recordOf(w), - EventID: w.OriginatingEventID, TaskID: w.TaskID, - } - if !w.RetainedAt.IsZero() { - v.RetainedAt = w.RetainedAt.UTC().Format(time.RFC3339) - } - return v -} - -// openConnectWorktrees opens the ledger of the connector the active profile -// is set up as: the one it has, never a new one, and never a schema this -// binary would migrate under a connector that is running. -func openConnectWorktrees(app *appctx.App, shadow bool) (*connector.Worktrees, func(), error) { - if app == nil { - return nil, nil, errors.New("app not initialized") - } - name := app.Config.ActiveProfile - if name == "" { - return nil, nil, output.ErrUsageHint("Worktrees belong to a connector's profile", "Pass -P/--profile , a profile set up with `basecamp connect setup`.") - } - path, err := setup.Path(config.GlobalConfigDir(), name) - if err != nil { - return nil, nil, output.ErrUsage(err.Error()) - } - file, err := setup.Load(path) - switch { - case errors.Is(err, os.ErrNotExist): - return nil, nil, output.ErrUsageHint(fmt.Sprintf("Profile %q is not set up as a connector", name), "Run: basecamp connect setup -P "+shellQuote(name)) - case err != nil: - return nil, nil, output.ErrUsage("connect.json cannot be used: " + err.Error()) - } - // Named, not created: reading what a connector left must not make a - // state directory for a connector that never ran. - stateDir, err := connectStatePath(file, shadow) - if err != nil { - return nil, nil, output.ErrUsage("The connector's state directory cannot be used: " + err.Error()) - } - ledgerPath := filepath.Join(stateDir, connector.LedgerFile) - if _, err := os.Lstat(ledgerPath); err != nil { - if errors.Is(err, os.ErrNotExist) { - return nil, nil, output.ErrUsageHint("This connector has not run yet: there is no ledger in "+stateDir, "Run: basecamp connect -P "+shellQuote(name)) - } - return nil, nil, err - } - ledger, err := connector.OpenExistingLedger(context.Background(), ledgerPath) - if err != nil { - return nil, nil, err - } - wt, err := connector.NewWorktrees(connector.WorktreesOptions{ - Ledger: ledger, Root: filepath.Join(stateDir, connectWorktreesDir), - // What a removal refuses is said, not swallowed. - Logger: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn})), - }) - if err != nil { - _ = ledger.Close() - return nil, nil, err - } - return wt, func() { _ = ledger.Close() }, nil -} diff --git a/internal/commands/connect_worktrees_test.go b/internal/commands/connect_worktrees_test.go deleted file mode 100644 index 931f4f9bc..000000000 --- a/internal/commands/connect_worktrees_test.go +++ /dev/null @@ -1,155 +0,0 @@ -package commands - -import ( - "bytes" - "context" - "encoding/json" - "os" - "os/exec" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/basecamp/basecamp-cli/internal/appctx" - "github.com/basecamp/basecamp-cli/internal/config" - "github.com/basecamp/basecamp-cli/internal/connector" - "github.com/basecamp/basecamp-cli/internal/connector/admission" - "github.com/basecamp/basecamp-cli/internal/connector/setup" - "github.com/basecamp/basecamp-cli/internal/output" -) - -// worktreesCmdEnv is a set-up connector profile with a ledger holding one -// retained worktree whose directory the operator already removed. -func worktreesCmdEnv(t *testing.T) (*appctx.App, *bytes.Buffer, connector.Worktree) { - t.Helper() - root := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", filepath.Join(root, "config")) - t.Setenv("XDG_STATE_HOME", filepath.Join(root, "state")) - t.Setenv("USERPROFILE", root) - - file := setup.New("agent") - file.AccountID = "2914079" - file.Agent = setup.Agent{PersonID: 52007412, Kind: setup.KindAgent} - file.Trust.OperatorID = 26909558 - repo := filepath.Join(root, "repo") - require.NoError(t, os.MkdirAll(repo, 0o700)) - if _, err := exec.LookPath("git"); err != nil { - t.Skip("git is not installed") - } - for _, args := range [][]string{{"init", "-q", "-b", "main"}, {"commit", "-q", "--allow-empty", "-m", "init"}} { - cmd := exec.CommandContext(context.Background(), "git", append([]string{"-c", "user.name=T", "-c", "user.email=t@example.invalid"}, args...)...) - cmd.Dir = repo - cmd.Env = []string{"HOME=" + root, "PATH=" + os.Getenv("PATH")} - out, err := cmd.CombinedOutput() - require.NoError(t, err, string(out)) - } - file.Projects[48699913] = admission.Route{Path: repo} - path, err := setup.Path(config.GlobalConfigDir(), "agent") - require.NoError(t, err) - require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) - data, err := json.Marshal(file) - require.NoError(t, err) - require.NoError(t, os.WriteFile(path, data, 0o600)) - - stateDir, err := connectStateDir(file, false) - require.NoError(t, err) - ledger, err := connector.OpenLedger(filepath.Join(stateDir, connector.LedgerFile)) - require.NoError(t, err) - defer func() { _ = ledger.Close() }() - w := connector.Worktree{ - Path: filepath.Join(stateDir, "worktrees", "app-00000000", "7-abcdef"), Route: repo, Repository: repo, - Branch: connector.BranchPrefix + "7-abcdef", BaseCommit: "0123456789abcdef0123456789abcdef01234567", OriginatingEventID: 7, - } - w.WorkDir = w.Path - id, err := ledger.BeginWorktree(context.Background(), w) - require.NoError(t, err) - // Git's record of it, as the connector stores it once the worktree is - // made: what is left of an orphan. - w.AdminDir = filepath.Join(repo, ".git", "worktrees", "7-abcdef") - require.NoError(t, os.MkdirAll(w.AdminDir, 0o700)) - require.NoError(t, ledger.WorktreeAdminDir(context.Background(), id, w.AdminDir)) - require.NoError(t, ledger.RetainWorktree(context.Background(), id, connector.RetainedDirty, connector.WorktreeCreating)) - - cfg := config.Default() - cfg.ActiveProfile = "agent" - var out bytes.Buffer - app := &appctx.App{Config: cfg, Output: output.New(output.Options{Format: output.FormatJSON, Writer: &out})} - return app, &out, w -} - -func runWorktreesCmd(t *testing.T, app *appctx.App, args ...string) error { - t.Helper() - cmd := NewConnectCmd() - cmd.SetArgs(append([]string{"worktrees"}, args...)) - cmd.SetContext(appctx.WithApp(context.Background(), app)) - cmd.SetOut(&bytes.Buffer{}) - cmd.SetErr(&bytes.Buffer{}) - cmd.SilenceErrors = true - cmd.SilenceUsage = true - return cmd.Execute() -} - -func TestConnectWorktreesListShowsTheKeptOnes(t *testing.T) { - app, out, w := worktreesCmdEnv(t) - require.NoError(t, runWorktreesCmd(t, app, "list")) - assert.Contains(t, out.String(), w.Path) - assert.Contains(t, out.String(), `"reason": "dirty"`) -} - -// A listing says what each kept worktree takes up, and says nothing about the -// size of one that is gone. -func TestConnectWorktreesSayWhatTheyTakeUp(t *testing.T) { - app, out, w := worktreesCmdEnv(t) - require.NoError(t, os.MkdirAll(w.Path, 0o700)) - require.NoError(t, os.WriteFile(filepath.Join(w.Path, "notes.txt"), bytes.Repeat([]byte("x"), 1234), 0o600)) - require.NoError(t, runWorktreesCmd(t, app, "list")) - assert.Contains(t, out.String(), `"size_bytes": 1234`) - - out.Reset() - require.NoError(t, os.RemoveAll(w.Path)) - require.NoError(t, runWorktreesCmd(t, app, "prune")) - assert.Contains(t, out.String(), `"reason": "orphaned"`) - assert.NotContains(t, out.String(), `"size_bytes"`, "a worktree that is gone has no size") -} - -// An orphaned worktree is listed with git's record of it, which is what is -// left to deal with. -func TestConnectWorktreesShowTheRecordOfAnOrphan(t *testing.T) { - app, out, w := worktreesCmdEnv(t) - require.NoError(t, runWorktreesCmd(t, app, "list")) - assert.Contains(t, out.String(), `"record": "`+w.AdminDir+`"`) -} - -func TestConnectWorktreesPruneRefusesWhatItCannotName(t *testing.T) { - app, _, _ := worktreesCmdEnv(t) - err := runWorktreesCmd(t, app, "prune", "--force", "relative/path") - require.Error(t, err) - assert.Contains(t, err.Error(), "absolute path") - - err = runWorktreesCmd(t, app, "prune", "--force", "/not/a/kept/worktree") - require.Error(t, err) - assert.Contains(t, err.Error(), "Nothing was pruned") -} - -// A worktree whose directory is gone is reported as orphaned, with git's -// record of it, and a plain prune deletes none of what it left; the operator -// naming its path is what clears it. -func TestConnectWorktreesPruneLeavesAnOrphanAloneUntilItIsNamed(t *testing.T) { - app, out, w := worktreesCmdEnv(t) - require.NoError(t, runWorktreesCmd(t, app, "prune")) - assert.Contains(t, out.String(), `"reason": "orphaned"`) - assert.Contains(t, out.String(), `"record"`, "what is left of it") - assert.NotContains(t, out.String(), `"force_refused"`, "nothing was forced") - out.Reset() - require.NoError(t, runWorktreesCmd(t, app, "list")) - assert.Contains(t, out.String(), w.Path, "still listed for the operator") - - out.Reset() - require.NoError(t, runWorktreesCmd(t, app, "prune", "--force", w.Path)) - assert.Contains(t, out.String(), `"action": "forced"`) - out.Reset() - require.NoError(t, runWorktreesCmd(t, app, "list")) - assert.NotContains(t, out.String(), w.Path) -} diff --git a/internal/connector/admission/policy.go b/internal/connector/admission/policy.go index 7ca94ad2a..163a440ed 100644 --- a/internal/connector/admission/policy.go +++ b/internal/connector/admission/policy.go @@ -48,7 +48,7 @@ type Route struct { // verified identity) and the --project scope. // // connect.json as a whole is written by `basecamp connect setup` (plan step -// 16), which also carries the driver, concurrency, deadline and worktrees; +// 16), which also carries the driver, concurrency and deadline; // this is only the part admission decides from. type Policy struct { // AgentID is the agent's own Person id. Required. diff --git a/internal/connector/dispatch_route_refusal_test.go b/internal/connector/dispatch_route_refusal_test.go deleted file mode 100644 index f61a4e798..000000000 --- a/internal/connector/dispatch_route_refusal_test.go +++ /dev/null @@ -1,206 +0,0 @@ -package connector - -import ( - "context" - "errors" - "fmt" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/basecamp/basecamp-cli/internal/connector/admission" -) - -// failingWorkspaces fails every Prepare with one error, so a test says what -// kind of failure the dispatcher is looking at and nothing else. -type failingWorkspaces struct{ err error } - -func (w failingWorkspaces) Prepare(context.Context, string, int64) (string, error) { - return "", w.err -} - -func (w failingWorkspaces) Finish(context.Context, string, string) error { return nil } - -// unusableRoute is what Worktrees.Prepare returns for a route that is not a -// repository. -func unusableRoute() error { - return fmt.Errorf("connector: route %s is not in a git repository: %w", testRoute, ErrRouteUnusable) -} - -// The defect: a route no working directory could be made in left every -// record admitted, backed the route off and told nobody. It is now a -// refusal, in the ledger and on the recording that asked. -func TestARouteNoWorkingDirectoryCanBeMadeInIsRefused(t *testing.T) { - ctx := context.Background() - fake := newFakeDriver() - h := newDispatchHarness(t, fake, func(o *DispatcherOptions) { - o.Workspaces = failingWorkspaces{err: unusableRoute()} - }) - h.ledger.SetHooks(LifecycleHooks(h.ledger, LifecycleOptions{})) - admitRouted(t, h.ledger, 1, adapterBucketID, "recording:10304028989", testRoute) - h.run(t) - - // The state the record is actually in is what a failure here has to say, - // so the wait collects it rather than reporting a bare timeout. The - // harness helper that would describe the whole ledger lives in a - // unix-tagged file and this one is not, which is the same reason the - // permission cases moved out (Copilot on #753). - require.EventuallyWithT(t, func(c *assert.CollectT) { - r, ok, err := h.ledger.Get(ctx, 1) - assert.NoError(c, err) - assert.True(c, ok, "the record is gone") - assert.Equal(c, StateBlocked, r.State, "the record was never refused") - }, 5*time.Second, 10*time.Millisecond) - - r, _, err := h.ledger.Get(ctx, 1) - require.NoError(t, err) - assert.Equal(t, ReasonRouteUnusable, r.Reason, "connect status counts it under a reason a person can act on") - - in := obIntent(t, h.ledger, refusedStartKey(1)) - assert.Equal(t, IntentHoldingReply, in.Kind) - assert.Equal(t, Destination{BucketID: adapterBucketID, Kind: MessageComment, RecordingID: obReplyRecording}, in.Destination) - text := MessageText(in.Body) - assert.Contains(t, text, "is not a git repository with a commit") - assert.Contains(t, text, "basecamp connect redispatch 1") -} - -// The other direction: a Prepare that failed for a reason a retry can fix -// leaves the record where it is, to be tried again. -func TestARouteThatFailedOnceIsStillRetried(t *testing.T) { - ctx := context.Background() - fake := newFakeDriver() - h := newDispatchHarness(t, fake, func(o *DispatcherOptions) { - o.Workspaces = failingWorkspaces{err: errors.New("connector: route " + testRoute + " is not in a git repository: git rev-parse: exit status 128")} - }) - h.ledger.SetHooks(LifecycleHooks(h.ledger, LifecycleOptions{})) - admitRouted(t, h.ledger, 1, adapterBucketID, "recording:10304028989", testRoute) - h.run(t) - - assert.Never(t, func() bool { - r, ok, err := h.ledger.Get(ctx, 1) - return err == nil && ok && r.State == StateBlocked - }, time.Second, 20*time.Millisecond, "a transient failure is not a refusal") - - for _, in := range obIntents(t, h.ledger) { - assert.NotEqual(t, refusedStartKey(1), in.Key, "nothing was said, because nothing has been settled") - } -} - -// The reply is posted. It stands down only when the record leaves the state -// it answers for, which is the rule the no-route holding reply already had. -func TestTheRefusalReplyIsPostedAndStandsDownOnRedispatch(t *testing.T) { - t.Run("posted while the record is still refused", func(t *testing.T) { - ledger, clock := obLedger(t) - obAdmit(t, ledger, 1, "recording:10304028989") - ctx := context.Background() - require.NoError(t, ledger.RefuseStart(ctx, 1, ReasonRouteUnusable)) - - basecamp := newFakeBasecamp(clock.Now) - require.NoError(t, obOutbox(t, ledger, basecamp).Flush(ctx)) - assert.Equal(t, IntentSent, obIntent(t, ledger, refusedStartKey(1)).State) - }) - - t.Run("stood down once a person has redispatched it", func(t *testing.T) { - ledger, clock := obLedger(t) - obAdmit(t, ledger, 1, "recording:10304028989") - // The directory is made a repository and the record is decided again. - next := admittedVerdict(1, getRecord(t, ledger, 1).Revision+1, "recording:10304028989") - ctx := context.Background() - require.NoError(t, ledger.RefuseStart(ctx, 1, ReasonRouteUnusable)) - _, err := ledger.Admission().Commit(ctx, next) - require.NoError(t, err) - obLaunch(t, ledger, 1) - - basecamp := newFakeBasecamp(clock.Now) - require.NoError(t, obOutbox(t, ledger, basecamp).Flush(ctx)) - got := obIntent(t, ledger, refusedStartKey(1)) - assert.Equal(t, IntentCanceled, got.State) - assert.Equal(t, "no longer called for", got.Note) - }) -} - -// An event told there is no route is owed the second answer too: the route -// arrived, and the directory it named is not one a worktree can be made in. -func TestAnEventToldThereIsNoRouteIsToldAgainWhenTheRouteIsUnusable(t *testing.T) { - ledger, _ := obLedger(t) - seenRecord(t, ledger, 1) - noRoute := obNoRouteVerdict(1, 0, admission.ReplyDestination{Kind: admission.ReplyComment, RecordingID: obReplyRecording}) - ctx := context.Background() - _, err := ledger.Admission().Commit(ctx, noRoute) - require.NoError(t, err) - _, err = ledger.Admission().Commit(ctx, admittedVerdict(1, 1, "recording:10304028989")) - require.NoError(t, err) - require.NoError(t, ledger.RefuseStart(ctx, 1, ReasonRouteUnusable)) - - held, refused := obIntents(t, ledger), "" - for _, in := range held { - if in.Key == refusedStartKey(1) { - refused = in.Body - } - } - require.NotEmpty(t, refused, "the refusal was never said") - for _, in := range held { - if in.Key == holdingKey(1) { - assert.NotEqual(t, in.Body, refused, "two answers, not one overwritten by the other") - } - } -} - -// Two holding replies can be pending on one event at once, and each is an -// answer to one reason only. Neither ever stands in for the other: a reply -// that says the project is not routed must not go out once it is, even -// though the record is blocked again for a different reason (Copilot on -// #753). -func TestEachHoldingReplyAnswersForItsOwnReasonOnly(t *testing.T) { - reply := admission.ReplyDestination{Kind: admission.ReplyComment, RecordingID: obReplyRecording} - - t.Run("the no-route reply stands down once the project is routed", func(t *testing.T) { - ledger, clock := obLedger(t) - seenRecord(t, ledger, 1) - ctx := context.Background() - _, err := ledger.Admission().Commit(ctx, obNoRouteVerdict(1, 0, reply)) - require.NoError(t, err) - // The route arrives and is a directory no worktree can be made in, - // all before the outbox got to the first reply. - _, err = ledger.Admission().Commit(ctx, admittedVerdict(1, 1, "recording:10304028989")) - require.NoError(t, err) - require.NoError(t, ledger.RefuseStart(ctx, 1, ReasonRouteUnusable)) - - basecamp := newFakeBasecamp(clock.Now) - require.NoError(t, obOutbox(t, ledger, basecamp).Flush(ctx)) - stale := obIntent(t, ledger, holdingKey(1)) - assert.Equal(t, IntentCanceled, stale.State, "the project is routed now, so that answer is false") - assert.Equal(t, "no longer called for", stale.Note) - assert.Equal(t, IntentSent, obIntent(t, ledger, refusedStartKey(1)).State) - assert.Equal(t, 1, basecamp.postCount(), "one answer, the true one") - }) - - t.Run("the refusal stands down once the route is taken away", func(t *testing.T) { - ledger, clock := obLedger(t) - obAdmit(t, ledger, 1, "recording:10304028989") - ctx := context.Background() - require.NoError(t, ledger.RefuseStart(ctx, 1, ReasonRouteUnusable)) - // The project is unrouted instead of the directory being fixed, so - // the record waits on the other reason and the refusal is stale. - _, err := ledger.Admission().Commit(ctx, obNoRouteVerdict(1, getRecord(t, ledger, 1).Revision, reply)) - require.NoError(t, err) - - basecamp := newFakeBasecamp(clock.Now) - require.NoError(t, obOutbox(t, ledger, basecamp).Flush(ctx)) - assert.Equal(t, IntentCanceled, obIntent(t, ledger, refusedStartKey(1)).State) - assert.Equal(t, IntentSent, obIntent(t, ledger, holdingKey(1)).State) - assert.Equal(t, 1, basecamp.postCount()) - }) -} - -// A record that moved on since the dispatcher read it is left where it is. -func TestRefuseStartLeavesARecordThatMovedOn(t *testing.T) { - ledger, _ := obLedger(t) - obAdmit(t, ledger, 1, "recording:10304028989") - obLaunch(t, ledger, 1) - - require.Error(t, ledger.RefuseStart(context.Background(), 1, ReasonRouteUnusable)) - assert.Equal(t, StateDispatched, getRecord(t, ledger, 1).State) -} diff --git a/internal/connector/dispatcher.go b/internal/connector/dispatcher.go index 07332fdb9..5e5f4a25d 100644 --- a/internal/connector/dispatcher.go +++ b/internal/connector/dispatcher.go @@ -63,41 +63,6 @@ const ( // tools are mcp__basecamp__*. const MCPServerName = "basecamp" -// Workspaces decides the directory a task works in from its approved route. -// The default works in the route itself. -type Workspaces interface { - // Prepare returns the working directory for a task on route. - Prepare(ctx context.Context, route string, originatingEventID int64) (string, error) - // Finish is called once the task's worker is gone. - Finish(ctx context.Context, route, workDir string) error -} - -// PerTaskWorkspaces is a Workspaces that gives every task a directory of its -// own (a git worktree), so two tasks on one route do not share a working -// directory and the route itself is not held busy. The ledger still holds one -// live task per working directory. -type PerTaskWorkspaces interface { - Workspaces - PerTaskDirs() bool -} - -// WaitingWorkspaces is a Workspaces that knows some routes cannot take a -// task now — a repository whose worktree could not be made, say. The -// dispatcher leaves those routes out of the startable query, so records it -// could not start on them never fill the window ahead of other routes. -type WaitingWorkspaces interface { - Workspaces - RoutesWaiting() []string -} - -// RecoveringWorkspaces is a Workspaces with state of its own to reconcile on -// start. Recover runs after every attempt a previous process left live is -// settled. -type RecoveringWorkspaces interface { - Workspaces - Recover(ctx context.Context) error -} - // ReplyLister lists the agent's comments or chat lines at a reply destination, // for the adopted-reply rule. type ReplyLister interface { @@ -124,7 +89,6 @@ type DispatcherOptions struct { Launcher driver.Launcher // NoAutomaticRetry: never retry a failed spawn (sandbox mode). NoAutomaticRetry bool - Workspaces Workspaces // MCP names what the worker's Basecamp MCP server runs as. MCP WorkerMCP @@ -370,11 +334,6 @@ func (d *Dispatcher) Recover(ctx context.Context) error { worker, TokenHolder{Process: a.Taker.Identity(), Unaccounted: a.TakerUnaccounted}, AttemptEnd{AttemptID: a.AttemptID, Stop: StopLost}, nil) } - if w, ok := d.opts.Workspaces.(RecoveringWorkspaces); ok { - if err := w.Recover(ctx); err != nil { - return fmt.Errorf("connector: recover working directories: %w", err) - } - } return nil } @@ -458,19 +417,8 @@ func (d *Dispatcher) dispatchReady(ctx context.Context) error { // Invariant 2, in the query: only records whose route connect.json // approves now, in the projects this run hears, and on a directory no live // task holds. A record the dispatcher cannot start never fills the window. - startable := approved - if w, ok := d.opts.Workspaces.(WaitingWorkspaces); ok { - if waiting := w.RoutesWaiting(); len(waiting) > 0 { - startable = make(map[int64]string, len(approved)) - for bucket, route := range approved { - if !slices.Contains(waiting, route) { - startable[bucket] = route - } - } - } - } records, err := d.ledger.StartableRecordsWhere(ctx, StartableFilter{ - Routes: startable, RouteHeld: !d.perTaskDirs(), Limit: d.opts.Concurrency * 4, + Routes: approved, RouteHeld: true, Limit: d.opts.Concurrency * 4, }) if err != nil { return err @@ -541,17 +489,7 @@ func (d *Dispatcher) approvedRoutes() map[int64]string { return approved } -func (d *Dispatcher) perTaskDirs() bool { - w, ok := d.opts.Workspaces.(PerTaskWorkspaces) - return ok && w.PerTaskDirs() -} - func (d *Dispatcher) workDirBusy(route string) bool { - if d.perTaskDirs() { - // Each task gets its own directory; LaunchTask's unique working - // directory is what holds. - return false - } d.mu.Lock() defer d.mu.Unlock() for _, r := range d.live { @@ -567,36 +505,13 @@ func (d *Dispatcher) workDirBusy(route string) bool { // caller's question (free), not this one's. func (d *Dispatcher) start(ctx context.Context, record Record) error { route := record.Decision.Route - workDir := route - if d.opts.Workspaces != nil { - dir, err := d.opts.Workspaces.Prepare(ctx, route, record.ID) - switch { - case errors.Is(err, ErrRouteUnusable): - // The route is not a condition that passes: it is a directory a - // person has to change. Waiting for it is what made this - // invisible — the route backed off, the records stayed admitted, - // and nobody was told anything — so the record is blocked with - // the reason, which `connect status` counts and a redispatch - // clears, and the recording that asked is answered once. - d.log.Error("connector: no task can be given a working directory on this route", "event_id", record.ID, "error", err) - if err := d.ledger.RefuseStart(ctx, record.ID, ReasonRouteUnusable); err != nil { - return err - } - return nil - case err != nil: - d.log.Warn("connector: could not prepare a working directory", "event_id", record.ID, "error", err) - return nil - } - workDir = dir - } + // The working directory is the route, and nothing is prepared for it: the + // connector runs the worker where it was pointed, and a task that needs a + // directory of its own is the agent's business to make. launch, err := d.ledger.LaunchTask(ctx, LaunchSpec{ - EventID: record.ID, Route: route, WorkDir: workDir, Driver: d.opts.Driver.Name(), Deadline: d.opts.Deadline, + EventID: record.ID, Route: route, WorkDir: route, Driver: d.opts.Driver.Name(), Deadline: d.opts.Deadline, }) if err != nil { - // No task was created, so there is no attempt to release and no - // worker to confirm: the directory prepared for it was never a - // task's. - d.discardPreparedWorkspace(ctx, route, workDir) return err } d.line(DispatchLine{Type: "dispatch", TaskID: launch.TaskID, AttemptID: launch.AttemptID, EventIDs: launch.EventIDs, State: string(AttemptLaunching)}) @@ -962,13 +877,12 @@ func (d *Dispatcher) confirmTakerGone(worker driver.Process, holder TokenHolder) // left for the next start. const settleAttempts = 5 -// release is the ONE place an attempt is settled, its working directory -// released and its end reported: the single release point of the driver -// package's one-owner rule. Nothing else in the connector calls EndAttempt, -// Workspaces.Finish, or writes an ended dispatch line — a source test holds -// that (dispatcher_boundary_test.go). +// release is the ONE place an attempt is settled and its end reported: the +// single release point of the driver package's one-owner rule. Nothing else +// in the connector calls EndAttempt or writes an ended dispatch line — a +// source test holds that (dispatcher_boundary_test.go). // -// It releases nothing until the worker's process group is confirmed gone, and +// It settles nothing until the worker's process group is confirmed gone, and // nothing if the ledger refuses the settlement. Either way the attempt stays // live: its token, its conversation and its directory are still its own, a // person settles it, and this process stops counting it among the workers it @@ -1010,7 +924,6 @@ func (d *Dispatcher) release(ctx context.Context, launch Launch, worker driver.P // out either — it cancels the reads (Run) and waits only for this to // return. d.wg.Go(func() { d.adopt(ctx, settlement) }) - d.finishWorkspace(ctx, launch.Route, launch.WorkDir) d.line(DispatchLine{Type: "dispatch", TaskID: launch.TaskID, AttemptID: launch.AttemptID, State: string(AttemptEnded), StopReason: string(end.Stop)}) if run != nil { d.forget(launch.AttemptID) @@ -1039,28 +952,6 @@ func (d *Dispatcher) forget(attemptID string) { d.mu.Unlock() } -// finishWorkspace releases a task's working directory. It is the release -// point's alone: a directory is released only once the task that owned it is -// settled and its worker's group is confirmed gone. -func (d *Dispatcher) finishWorkspace(ctx context.Context, route, workDir string) { - d.workspaceFinished(ctx, route, workDir) -} - -// discardPreparedWorkspace releases a directory prepared for a task that was -// never created, so no worker ever ran in it. -func (d *Dispatcher) discardPreparedWorkspace(ctx context.Context, route, workDir string) { - d.workspaceFinished(ctx, route, workDir) -} - -func (d *Dispatcher) workspaceFinished(ctx context.Context, route, workDir string) { - if d.opts.Workspaces == nil || workDir == "" { - return - } - if err := d.opts.Workspaces.Finish(ctx, route, workDir); err != nil { - d.log.Warn("connector: finishing a working directory", "error", err) - } -} - // AdoptionBudget bounds the reads one settlement spends on the adopted-reply // rule. Settlement runs on a context a shutdown does not cancel — an attempt // half-settled is worse than a shutdown that takes a moment — but adoption diff --git a/internal/connector/dispatcher_boundary_test.go b/internal/connector/dispatcher_boundary_test.go index c7d73c01b..8c50fa89b 100644 --- a/internal/connector/dispatcher_boundary_test.go +++ b/internal/connector/dispatcher_boundary_test.go @@ -11,17 +11,16 @@ import ( ) // The one release point, as a property of the source rather than of a -// reviewer's attention: settling an attempt, releasing a working directory -// and reporting an end happen in Dispatcher.release and nowhere else, so no -// later card can add a path that releases a directory while a worker may -// still be in it. -func TestOnlyTheReleasePointSettlesAnAttemptOrReleasesItsDirectory(t *testing.T) { +// reviewer's attention: settling an attempt and reporting an end happen in +// Dispatcher.release and nowhere else, so no later card can add a path that +// settles an attempt while a worker may still be running. +func TestOnlyTheReleasePointSettlesAnAttempt(t *testing.T) { source, err := os.ReadFile("dispatcher.go") require.NoError(t, err) functions := splitFunctions(string(source)) require.NotEmpty(t, functions) - for _, call := range []string{"EndAttempt(", "finishWorkspace(", "d.settle(", "d.adopt("} { + for _, call := range []string{"EndAttempt(", "d.settle(", "d.adopt("} { for name, body := range functions { if name == "release" || name == call[:len(call)-1] || (name == "settle" && call == "EndAttempt(") { continue @@ -29,14 +28,6 @@ func TestOnlyTheReleasePointSettlesAnAttemptOrReleasesItsDirectory(t *testing.T) assert.NotContains(t, body, call, "%s calls %s outside the release point", name, call) } } - // The only other way to release a directory is one no task ever owned. - for name, body := range functions { - switch name { - case "finishWorkspace", "discardPreparedWorkspace", "workspaceFinished": - continue - } - assert.NotContains(t, body, "Workspaces.Finish(", "%s releases a working directory of its own accord", name) - } for name, body := range functions { if name == "release" { continue diff --git a/internal/connector/dispatcher_test.go b/internal/connector/dispatcher_test.go index e2df31999..880aa9339 100644 --- a/internal/connector/dispatcher_test.go +++ b/internal/connector/dispatcher_test.go @@ -10,7 +10,6 @@ import ( "os" "os/exec" "path/filepath" - "slices" "strconv" "strings" "sync" @@ -612,57 +611,6 @@ func TestAFollowUpForAOneShotDriverStartsATaskOfItsOwn(t *testing.T) { assert.Zero(t, unknown, "never exposed on the first task, so not unknown there") } -type fakeWorkspaces struct { - perTask bool - mu sync.Mutex - n int - finished int - recovered bool -} - -func (w *fakeWorkspaces) Prepare(_ context.Context, route string, eventID int64) (string, error) { - w.mu.Lock() - defer w.mu.Unlock() - w.n++ - return route + "-wt-" + string(rune('0'+w.n)), nil -} -func (w *fakeWorkspaces) Finish(context.Context, string, string) error { - w.mu.Lock() - w.finished++ - w.mu.Unlock() - return nil -} -func (w *fakeWorkspaces) PerTaskDirs() bool { return w.perTask } -func (w *fakeWorkspaces) Recover(context.Context) error { - w.mu.Lock() - w.recovered = true - w.mu.Unlock() - return nil -} - -func TestPerTaskWorkspacesLetTwoTasksShareARoute(t *testing.T) { - fake := newFakeDriver() - hold := make(chan struct{}) - fake.turn = func(s *fakeSession, _ int, _ string) (driver.PromptResult, error) { - select { - case <-hold: - case <-s.canceled: - return driver.PromptResult{Stop: driver.TurnCanceled}, nil - } - return driver.PromptResult{Stop: driver.TurnEndTurn}, nil - } - ws := &fakeWorkspaces{perTask: true} - h := newDispatchHarness(t, fake, func(o *DispatcherOptions) { o.Workspaces = ws }) - admitOn(t, h.ledger, 1, "recording:1") - admitOn(t, h.ledger, 2, "recording:2") - h.run(t) - a, b := nextSession(t, fake), nextSession(t, fake) - assert.NotEqual(t, a.cfg.Cwd, b.cfg.Cwd) - close(hold) - h.attemptsEnded(t, 2) - assert.True(t, ws.recovered, "Recover runs on start") -} - func nextSession(t *testing.T, fake *fakeDriver) *fakeSession { t.Helper() select { @@ -945,9 +893,7 @@ func TestATaskWithASurvivingGrandchildNeverReleasesItsDirectory(t *testing.T) { // The session reports the worker's group, which still has a member, and // closing it kills nothing. fake.process = worker.Process() - ws := &fakeWorkspaces{} h := newDispatchHarness(t, fake, func(o *DispatcherOptions) { - o.Workspaces = ws o.CancelGrace = 200 * time.Millisecond }) // Confirmation without signaling, so the fixture's tree survives the @@ -973,9 +919,6 @@ func TestATaskWithASurvivingGrandchildNeverReleasesItsDirectory(t *testing.T) { attempt := liveAttemptID(t, h.ledger) assert.Equal(t, "running", readAttempt(t, h.ledger, attempt).State, "the record is not terminal") assert.Equal(t, StateDispatched, getRecord(t, h.ledger, 1).State) - ws.mu.Lock() - defer ws.mu.Unlock() - assert.Zero(t, ws.finished, "the working directory is not released") } // liveAttemptID is the id of the one attempt that has not ended. @@ -1014,10 +957,8 @@ func TestRecoveryReleasesNothingWhileTheRecordedGroupSurvives(t *testing.T) { worker, grandchild := drivertest.SurvivingWorker(t, work) fake := newFakeDriver() - ws := &fakeWorkspaces{} lines := &safeBuffer{} h := newDispatchHarness(t, fake, func(o *DispatcherOptions) { - o.Workspaces = ws o.Lines = ndjson.NewWriter(lines) o.CancelGrace = 100 * time.Millisecond }) @@ -1042,19 +983,14 @@ func TestRecoveryReleasesNothingWhileTheRecordedGroupSurvives(t *testing.T) { assert.Equal(t, "running", readAttempt(t, h.ledger, l.AttemptID).State, "the record is not terminal") assert.Equal(t, StateDispatched, getRecord(t, h.ledger, 1).State) assert.True(t, drivertest.Alive(grandchild)) - ws.mu.Lock() - assert.Zero(t, ws.finished, "the working directory is not released") - ws.mu.Unlock() assert.NotContains(t, lines.String(), `"state":"ended"`, "and no end is reported") } // Copilot r4: a settlement that cannot be written releases nothing either. func TestASettlementThatCannotBeWrittenReleasesNothing(t *testing.T) { fake := newFakeDriver() - ws := &fakeWorkspaces{} lines := &safeBuffer{} h := newDispatchHarness(t, fake, func(o *DispatcherOptions) { - o.Workspaces = ws o.Lines = ndjson.NewWriter(lines) }) h.ledger.SetHooks(Hooks{AttemptEnded: func(context.Context, Tx, Settlement) error { @@ -1070,17 +1006,10 @@ func TestASettlementThatCannotBeWrittenReleasesNothing(t *testing.T) { attempts, err := h.ledger.LiveAttempts(context.Background()) require.NoError(t, err) require.Len(t, attempts, 1, "the attempt stays live") - assert.Zero(t, ws.finishedCount(), "its directory is not released") assert.NotContains(t, lines.String(), `"state":"ended"`, "and no end is reported") assert.Equal(t, StateDispatched, getRecord(t, h.ledger, 1).State) } -func (w *fakeWorkspaces) finishedCount() int { - w.mu.Lock() - defer w.mu.Unlock() - return w.finished -} - func liveRuns(h *dispatchHarness) int { h.d.mu.Lock() defer h.d.mu.Unlock() @@ -1095,8 +1024,7 @@ func TestAStartThatFailedAfterLaunchingReleasesNothingWhileItsGroupLives(t *test fake := newFakeDriver() fake.startErr = []error{&driver.StartError{Process: worker, Err: errors.New("handshake timed out")}} - ws := &fakeWorkspaces{} - h := newDispatchHarness(t, fake, func(o *DispatcherOptions) { o.Workspaces = ws; o.CancelGrace = 100 * time.Millisecond }) + h := newDispatchHarness(t, fake, func(o *DispatcherOptions) { o.CancelGrace = 100 * time.Millisecond }) h.d.confirmGroupGone = func(p driver.Process, _ time.Duration) error { if driver.GroupMembersRemain(p) { return driver.ErrGroupOutlivedLeader @@ -1112,7 +1040,6 @@ func TestAStartThatFailedAfterLaunchingReleasesNothingWhileItsGroupLives(t *test return err == nil && len(attempts) == 1 && liveRuns(h) == 0 && h.d.heldCount() == 1 }, 5*time.Second, 20*time.Millisecond) assert.True(t, drivertest.Alive(grandchild)) - assert.Zero(t, ws.finishedCount(), "the directory is not released") assert.Equal(t, StateDispatched, getRecord(t, h.ledger, 1).State, "the record is not terminal") } @@ -1141,35 +1068,6 @@ func TestAWorkerThatExitsNonZeroMidTurnFailedAndOneThatVanishedIsLost(t *testing } } -type waitingWorkspaces struct { - fakeWorkspaces - waiting []string -} - -func (w *waitingWorkspaces) Prepare(_ context.Context, route string, _ int64) (string, error) { - if slices.Contains(w.waiting, route) { - return "", errors.New("the repository cannot take a worktree") - } - return route, nil -} - -func (w *waitingWorkspaces) RoutesWaiting() []string { return w.waiting } - -// Card 19: a route that cannot take a task must not starve the others. -func TestAFailingRouteDoesNotStarveTheOthers(t *testing.T) { - fake := newFakeDriver() - ws := &waitingWorkspaces{waiting: []string{"/work/broken"}} - h := newDispatchHarness(t, fake, func(o *DispatcherOptions) { o.Workspaces = ws }) - h.routes[700] = admission.Route{Path: "/work/broken"} - for i := int64(1); i <= 12; i++ { - admitRouted(t, h.ledger, i, 700, "recording:broken"+strconv.FormatInt(i, 10), "/work/broken") - } - admitRouted(t, h.ledger, 50, adapterBucketID, "recording:ok", testRoute) - h.run(t) - s := nextSession(t, fake) - assert.Equal(t, int64(50), s.cfg.Scope.EventIDs[0]) -} - // The redaction rule at the connector's end (driver's redact.go): the task's // own token, taken from the socket by the worker, comes back in what the // driver reports, and nothing the dispatcher writes carries it. diff --git a/internal/connector/ledger.go b/internal/connector/ledger.go index 451316e93..905a8e616 100644 --- a/internal/connector/ledger.go +++ b/internal/connector/ledger.go @@ -831,9 +831,11 @@ END; // outbox.go for the invariants it holds. migrationOutbox, - // Migration 9. The git worktrees a task runs in: the row written before - // `git worktree add`, the states it moves through, and the ones kept when - // a task ends holding work. See ledger_worktrees.go. + // Migration 9. The git worktrees a task ran in. Worktrees are gone — + // migration 12 drops the table — but this one stays where it is, exactly + // as it shipped: a ledger already at 9 or later has applied it, and a + // fresh ledger has to walk the same numbers to reach 12. Deleting it + // would make every later migration mean something else. // // 8, 9 and 10 were each written as 8 on their own branch, against // different predecessors. They ship together here, so the order is @@ -857,8 +859,78 @@ END; // already posted made. It rebuilds the outbox table, which migration 8 // shipped with a CHECK that knows four kinds. See outbox.go. migrationRetraction, + + // Migration 12. The worktrees table goes. Nothing makes a worktree any + // more: the connector runs a task where it was started, and a task that + // needs a directory of its own makes one. The rows it held were a record + // of directories on this machine, not of work anyone asked for, and no + // query reads them. + // + // The directories themselves are not touched. A worktree the connector + // kept is still on disk, still a git worktree of its repository, still + // holding whatever it held — dropping this table forgets the connector's + // account of it and nothing else. `git worktree list` in the repository + // still finds it, and `git worktree remove` still removes it. + migrationDropWorktrees, } +// migrationWorktrees is migration 9 as it shipped: the worktrees a task ran +// in. Nothing reads it — migration 12 drops the table — and it is kept here +// only because a migration that has been applied is never taken out of the +// list. Do not edit it. +const migrationWorktrees = ` +CREATE TABLE worktrees ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + path TEXT NOT NULL, + work_dir TEXT NOT NULL, + route TEXT NOT NULL, + repository TEXT NOT NULL, + branch TEXT NOT NULL, + base_commit TEXT NOT NULL, + originating_event_id INTEGER NOT NULL, + branch_created INTEGER NOT NULL DEFAULT 0, + admin_dir TEXT NOT NULL DEFAULT '', + task_id INTEGER REFERENCES tasks (id), + state TEXT NOT NULL + CHECK (state IN ('creating', 'live', 'retained', 'removing', 'removed')), + retained_reason TEXT NOT NULL DEFAULT '' + CHECK (retained_reason IN ('', 'dirty', 'unpushed', 'locked', 'moved', 'unverified', 'finished', 'orphaned')), + created_at TEXT NOT NULL, + finished_at TEXT, + retained_at TEXT, + removed_at TEXT, + removed_by TEXT NOT NULL DEFAULT '' + CHECK (removed_by IN ('', 'prune', 'prune_forced', 'missing', 'never_created')), + CHECK (state <> 'retained' OR retained_reason <> ''), + CHECK ((state = 'removed') = (removed_by <> '')) +); +CREATE UNIQUE INDEX worktrees_open_path ON worktrees (path) WHERE state <> 'removed'; +CREATE UNIQUE INDEX worktrees_open_work_dir ON worktrees (work_dir) WHERE state <> 'removed'; +CREATE INDEX worktrees_state ON worktrees (state); + +CREATE TRIGGER worktrees_state_edges +BEFORE UPDATE OF state ON worktrees +WHEN NEW.state <> OLD.state AND NOT ( + (OLD.state = 'creating' AND NEW.state IN ('live', 'retained', 'removing', 'removed')) + OR (OLD.state = 'live' AND NEW.state IN ('retained', 'removing', 'removed')) + OR (OLD.state = 'retained' AND NEW.state IN ('removing', 'removed')) + OR (OLD.state = 'removing' AND NEW.state IN ('retained', 'removed'))) +BEGIN + SELECT RAISE(ABORT, 'a worktree state moves along its edges only'); +END; +` + +// migrationDropWorktrees drops what migration 9 made. Dropping the table +// takes its indexes and its trigger with it; they are named here anyway so +// this migration reads as the undoing of that one. +const migrationDropWorktrees = ` +DROP TRIGGER IF EXISTS worktrees_state_edges; +DROP INDEX IF EXISTS worktrees_open_path; +DROP INDEX IF EXISTS worktrees_open_work_dir; +DROP INDEX IF EXISTS worktrees_state; +DROP TABLE IF EXISTS worktrees; +` + func (l *Ledger) migrate(ctx context.Context) error { if _, err := l.db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations ( version INTEGER PRIMARY KEY, diff --git a/internal/connector/ledger_dispatch.go b/internal/connector/ledger_dispatch.go index 6d6174c80..2a42c5d44 100644 --- a/internal/connector/ledger_dispatch.go +++ b/internal/connector/ledger_dispatch.go @@ -54,9 +54,11 @@ import ( // dispatched blocked dispatcher (withdrawExposure) exposed at launch, spawn failed again // dispatched completed worker (complete_dispatch), dispatcher the outcome, reported or settled // admitted queued lifecycle bookkeeping — -// admitted blocked dispatcher (RefuseStart) no working directory on the route +// admitted blocked — an edge the lifecycle map allows and no +// caller takes today // admitted discarded operator discard -// queued blocked dispatcher (RefuseStart) no working directory on the route +// queued blocked — an edge the lifecycle map allows and no +// caller takes today // queued discarded operator discard // completed — nobody terminal // discarded — nobody terminal diff --git a/internal/connector/ledger_migrations_test.go b/internal/connector/ledger_migrations_test.go new file mode 100644 index 000000000..ae5fe33d7 --- /dev/null +++ b/internal/connector/ledger_migrations_test.go @@ -0,0 +1,60 @@ +package connector + +import ( + "context" + "database/sql" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Worktrees are gone, and the ledger forgets them: migration 12 drops the +// table migration 9 made. Migration 9 itself stays in the list, unedited — +// a ledger that has applied it never sees it renumbered, and a fresh ledger +// walks the same numbers to reach 12 — so the shape this proves is "made, +// then dropped", on a ledger that really did run 9. +func TestTheWorktreesTableIsDroppedFromALedgerThatHadOne(t *testing.T) { + ctx := context.Background() + path := filepath.Join(t.TempDir(), "state", "connector.db") + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) + + old, err := sql.Open("sqlite", ledgerDSN(path, true)) + require.NoError(t, err) + _, err = old.ExecContext(ctx, `CREATE TABLE schema_migrations (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)`) + require.NoError(t, err) + for i := range len(migrations) - 1 { + _, err = old.ExecContext(ctx, migrations[i]) + require.NoError(t, err, "migration %d", i+1) + _, err = old.ExecContext(ctx, `INSERT INTO schema_migrations (version, applied_at) VALUES (?, 'then')`, i+1) + require.NoError(t, err) + } + // A ledger with a worktree in it, as one that ran with them on would have. + _, err = old.ExecContext(ctx, ` +INSERT INTO worktrees (path, work_dir, route, repository, branch, base_commit, originating_event_id, state, retained_reason, created_at, retained_at) +VALUES ('/w/one', '/w/one/app', '/repo/app', '/repo', 'basecamp-connect/1-a1b2c3', 'abc', 1, 'retained', 'dirty', 'then', 'then')`) + require.NoError(t, err, "migration 9 made the table this row goes in") + require.NoError(t, old.Close()) + + // The connector's own open makes the file private; a raw sql.Open does + // not, and the privacy check refuses what it finds. + for _, name := range []string{path, path + "-wal", path + "-shm"} { + if _, err := os.Stat(name); err == nil { + require.NoError(t, os.Chmod(name, 0o600)) + } + } + + ledger, err := OpenLedger(path) + require.NoError(t, err) + t.Cleanup(func() { _ = ledger.Close() }) + version, err := ledger.SchemaVersion(ctx) + require.NoError(t, err) + assert.Equal(t, len(migrations), version, "the upgrade ran") + + var left int + require.NoError(t, ledger.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM sqlite_master WHERE name LIKE 'worktrees%'`).Scan(&left)) + assert.Zero(t, left, "the table, its indexes and its trigger are all gone") +} diff --git a/internal/connector/ledger_status.go b/internal/connector/ledger_status.go index 877c861b1..445401001 100644 --- a/internal/connector/ledger_status.go +++ b/internal/connector/ledger_status.go @@ -107,16 +107,10 @@ type Status struct { AuthorizedBlocked int `json:"authorized_blocked"` RedispatchPending int `json:"redispatch_pending"` - Tasks []TaskStatus `json:"live_tasks"` - Worktrees []WorktreeStatus `json:"retained_worktrees"` - // WorktreesKnown is false when the retained worktrees could not be - // listed: they are unavailable, not known to be none. - WorktreesKnown bool `json:"worktrees_known"` - // WorktreesUnavailable is why, when a listing was tried and failed. - WorktreesUnavailable string `json:"worktrees_unavailable,omitempty"` - Indeterminate []IntentStatus `json:"indeterminate_intents"` - Held []HeldStatus `json:"held_records"` - Dispatches []DispatchStatus `json:"dispatches"` + Tasks []TaskStatus `json:"live_tasks"` + Indeterminate []IntentStatus `json:"indeterminate_intents"` + Held []HeldStatus `json:"held_records"` + Dispatches []DispatchStatus `json:"dispatches"` } // ConnectionStatus is the run command's own record of its last run: running @@ -216,14 +210,6 @@ func statusIdentity(pid, pgid int, started *time.Time) driver.Process { return recordedIdentity(pid, pgid, *started) } -// WorktreeStatus is a retained worktree, as the worktree lister reports it. -type WorktreeStatus struct { - Path string `json:"path"` - Branch string `json:"branch,omitempty"` - TaskID int64 `json:"task_id,omitempty"` - Reason string `json:"reason,omitempty"` -} - // IntentStatus is a lifecycle message waiting for a person. The body is not // shown. type IntentStatus struct { @@ -271,35 +257,10 @@ type DispatchedEvent struct { Withdrawn bool `json:"withdrawn,omitempty"` } -// WorktreeLister lists retained worktrees for status. Ledger.RetainedWorktreeStatus -// is the one status and doctor run; nil means the caller cannot say whether -// any are retained — which status reports as unavailable, never as none, as -// it does a listing that fails. -type WorktreeLister func(ctx context.Context) ([]WorktreeStatus, error) - // Status reads everything the ledger's own status shows in one read -// transaction, so the numbers agree with each other, and then asks the -// worktree lister. The lister runs after that transaction ends, never -// inside it: the ledger holds one connection, and a lister that reads the -// ledger too would wait for the connection the transaction holds. -func (l *Ledger) Status(ctx context.Context, worktrees WorktreeLister) (Status, error) { - s, err := l.status(ctx) - if err != nil { - return Status{}, err - } - if worktrees != nil { - if s.Worktrees, err = worktrees(ctx); err != nil { - // A status that cannot list them says so and is still worth - // reading; what it never does is call them none. - s.Worktrees, s.WorktreesUnavailable = nil, err.Error() - } else { - s.WorktreesKnown = true - } - } - if s.Worktrees == nil { - s.Worktrees = []WorktreeStatus{} - } - return s, nil +// transaction, so the numbers agree with each other. +func (l *Ledger) Status(ctx context.Context) (Status, error) { + return l.status(ctx) } func (l *Ledger) status(ctx context.Context) (Status, error) { diff --git a/internal/connector/ledger_tasks.go b/internal/connector/ledger_tasks.go index b5b6c563e..25e0f0723 100644 --- a/internal/connector/ledger_tasks.go +++ b/internal/connector/ledger_tasks.go @@ -162,13 +162,6 @@ const OutcomeUnknown Outcome = "unknown" // second time. It waits for a person's redispatch. const ReasonSpawnFailed = "spawn_failed" -// ReasonRouteUnusable blocks an event no task could be given a working -// directory for: with worktrees on, a route that is not in a git repository -// or has no commit to branch from. It is not on a timer — the directory is -// what has to change — so it waits for a person's redispatch, like every -// other reason a machine, not a moment, is the cause of. -const ReasonRouteUnusable = "route_unusable" - // Errors from the task ledger. var ( // ErrNotStartable is a launch for a record that is not waiting for a @@ -206,23 +199,12 @@ type Hooks struct { AttemptEnded func(ctx context.Context, tx Tx, s Settlement) error // StillRunning runs in StillRunning's transaction. StillRunning func(ctx context.Context, tx Tx, tick StillRunningTick) error - // StartRefused runs in RefuseStart's transaction, after the record is - // blocked: where the reply saying nothing will be started is called for. - StartRefused func(ctx context.Context, tx Tx, r RefusedStart) error // RecordDecided runs in the transaction of a person's decision on a // record — a redispatch or a discard: where a notice already posted stops // asking for anything, and is retracted. RecordDecided func(ctx context.Context, tx Tx, d RecordDecision) error } -// RefusedStart is what StartRefused is told. Everything else about the -// record — where a reply goes, and whether it asked for one — the hook reads -// in the transaction, as the verdict hook does. -type RefusedStart struct { - EventID int64 - Reason string -} - // RecordDecision is what RecordDecided is told: the record a person decided, // how, and when. type RecordDecision struct { @@ -264,9 +246,8 @@ type LaunchSpec struct { // Route is the approved directory; it must be the route the record // carries. Route string - // WorkDir is the directory the worker works in: Route itself, or a - // directory made for the task from it (a git worktree). Empty means - // Route. One live task holds a working directory. + // WorkDir is the directory the worker works in: Route itself. Empty + // means Route. One live task holds a working directory. WorkDir string // Driver is the driver's name. Driver string @@ -293,57 +274,6 @@ type Launch struct { DeadlineAt time.Time } -// RefuseStart blocks a record no task could be started for, with the reason -// why, and calls for the reply that says so. It is the dispatcher's word for -// a machine only a person can change: nothing ran, nothing will until the -// machine changes, and the ledger, `connect status` and the recording that -// asked all say the same thing. -// -// It is not a failure to be retried, and it is not silence. A record whose -// start cannot even be attempted has nowhere else to go: it is not -// dispatched, so no attempt settles it and no completion notice is rendered -// for it, and leaving it admitted is leaving it to be tried again forever -// against a directory that will not change on its own. -// -// Only a record still waiting for a worker is refused. One that moved on -// since the dispatcher read it — discarded, or started by another path — is -// left where it is and the refusal is reported to the caller. -func (l *Ledger) RefuseStart(ctx context.Context, eventID int64, reason string) error { - if reason == "" { - return errors.New("connector: a refused start needs a reason") - } - return retryBusy(func() error { return l.refuseStart(ctx, eventID, reason) }) -} - -func (l *Ledger) refuseStart(ctx context.Context, eventID int64, reason string) error { - tx, err := l.db.BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf("connector: begin refusal of %d: %w", eventID, err) - } - defer func() { _ = tx.Rollback() }() - - moved, err := l.move(ctx, tx, transition{ - id: eventID, state: StateBlocked, reason: reason, - from: []RecordState{StateAdmitted, StateQueued}, - }) - if err != nil { - return err - } - if !moved { - _ = tx.Rollback() - return l.explainRefusal(ctx, eventID, StateBlocked) - } - if l.hooks.StartRefused != nil { - if err := l.hooks.StartRefused(ctx, tx, RefusedStart{EventID: eventID, Reason: reason}); err != nil { - return fmt.Errorf("connector: refusal hook for %d: %w", eventID, err) - } - } - if err := tx.Commit(); err != nil { - return fmt.Errorf("connector: commit refusal of %d: %w", eventID, err) - } - return nil -} - // LaunchTask writes a task, its first attempt as launching, and its // originating event exposed, in one transaction (invariant 1). Records on the // same conversation that wait for a worker join the task at delivery diff --git a/internal/connector/ledger_worktrees.go b/internal/connector/ledger_worktrees.go deleted file mode 100644 index b598c74e5..000000000 --- a/internal/connector/ledger_worktrees.go +++ /dev/null @@ -1,390 +0,0 @@ -package connector - -import ( - "context" - "database/sql" - "errors" - "fmt" - "time" -) - -// Worktrees in the ledger: every git worktree the connector made for a task, -// from the moment it decided to make one until it is gone. -// -// A row is written creating before `git worktree add` runs, so a crash at any -// point leaves a row that says a directory may exist; live once the worktree -// is there; retained, with a reason, when the task ended and the worktree -// held work that was not safe to remove; removing while a removal holds the -// worktrees lock; removed at the end, with who removed it (straight from any -// open state when the directory is found gone). The states move along those -// edges only, held by a trigger. -const migrationWorktrees = ` -CREATE TABLE worktrees ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - path TEXT NOT NULL, - work_dir TEXT NOT NULL, - route TEXT NOT NULL, - repository TEXT NOT NULL, - branch TEXT NOT NULL, - base_commit TEXT NOT NULL, - originating_event_id INTEGER NOT NULL, - branch_created INTEGER NOT NULL DEFAULT 0, - admin_dir TEXT NOT NULL DEFAULT '', - task_id INTEGER REFERENCES tasks (id), - state TEXT NOT NULL - CHECK (state IN ('creating', 'live', 'retained', 'removing', 'removed')), - retained_reason TEXT NOT NULL DEFAULT '' - CHECK (retained_reason IN ('', 'dirty', 'unpushed', 'locked', 'moved', 'unverified', 'finished', 'orphaned')), - created_at TEXT NOT NULL, - finished_at TEXT, - retained_at TEXT, - removed_at TEXT, - removed_by TEXT NOT NULL DEFAULT '' - CHECK (removed_by IN ('', 'prune', 'prune_forced', 'missing', 'never_created')), - CHECK (state <> 'retained' OR retained_reason <> ''), - CHECK ((state = 'removed') = (removed_by <> '')) -); -CREATE UNIQUE INDEX worktrees_open_path ON worktrees (path) WHERE state <> 'removed'; -CREATE UNIQUE INDEX worktrees_open_work_dir ON worktrees (work_dir) WHERE state <> 'removed'; -CREATE INDEX worktrees_state ON worktrees (state); - -CREATE TRIGGER worktrees_state_edges -BEFORE UPDATE OF state ON worktrees -WHEN NEW.state <> OLD.state AND NOT ( - (OLD.state = 'creating' AND NEW.state IN ('live', 'retained', 'removing', 'removed')) - OR (OLD.state = 'live' AND NEW.state IN ('retained', 'removing', 'removed')) - OR (OLD.state = 'retained' AND NEW.state IN ('removing', 'removed')) - OR (OLD.state = 'removing' AND NEW.state IN ('retained', 'removed'))) -BEGIN - SELECT RAISE(ABORT, 'a worktree state moves along its edges only'); -END; -` - -// WorktreeState is where a task's worktree is. -type WorktreeState string - -const ( - WorktreeCreating WorktreeState = "creating" - WorktreeLive WorktreeState = "live" - WorktreeRetained WorktreeState = "retained" - WorktreeRemoving WorktreeState = "removing" - WorktreeRemoved WorktreeState = "removed" -) - -// RetainedReason is why a worktree was kept. -type RetainedReason string - -const ( - // RetainedDirty is uncommitted work: modified or untracked files, or a - // merge, rebase, cherry-pick, revert or bisect in progress. - RetainedDirty RetainedReason = "dirty" - // RetainedUnpushed is a commit no remote branch and no other local branch - // holds. - RetainedUnpushed RetainedReason = "unpushed" - // RetainedLocked is a worktree someone locked with `git worktree lock`. - RetainedLocked RetainedReason = "locked" - // RetainedUnverified is a worktree whose state could not be read. It is - // kept, because a check that failed proves nothing is safe to delete. - RetainedUnverified RetainedReason = "unverified" - // RetainedMoved is a worktree that is no longer where the ledger says: - // someone moved it, and its files are theirs to deal with. - RetainedMoved RetainedReason = "moved" - // RetainedOrphaned is a worktree whose directory something outside the - // connector removed. Git's record of it and the task branch are still - // there, reaching whatever they reach; the connector neither judges that - // nor deletes any of it. An operator's explicit discard does, and is - // told what goes. - RetainedOrphaned RetainedReason = "orphaned" - // RetainedFinished is a worktree whose task ended. Nothing the connector - // does removes a worktree, so this is why most kept worktrees are kept: - // the work is done with, and an operator says when it goes. - RetainedFinished RetainedReason = "finished" -) - -// RemovedBy is who removed a worktree. -type RemovedBy string - -const ( - // There is no connector: nothing the connector does of its own accord - // removes a worktree. - RemovedByPrune RemovedBy = "prune" - RemovedByPruneForced RemovedBy = "prune_forced" - RemovedMissing RemovedBy = "missing" - RemovedNeverCreated RemovedBy = "never_created" -) - -// Worktree is a worktree's ledger row. -type Worktree struct { - ID int64 - // Path is the worktree's root; WorkDir is where the task worked in it, - // the route's place inside the repository. - Path string - WorkDir string - Route string - Repository string - Branch string - BaseCommit string - OriginatingEventID int64 - // BranchCreated is this row's proof that the connector made the task - // branch, so deleting it can never delete someone else's. - BranchCreated bool - // AdminDir is the repository's own record of this worktree - // (/.git/worktrees/), which says where it is even after - // someone moves it or changes what it has checked out. - AdminDir string - // TaskID is the task that last worked in it; zero before one launched. - TaskID int64 - State WorktreeState - RetainedReason RetainedReason - CreatedAt time.Time - FinishedAt time.Time - RetainedAt time.Time - RemovedAt time.Time - RemovedBy RemovedBy -} - -const worktreeColumns = `id, path, work_dir, route, repository, branch, base_commit, originating_event_id, branch_created, admin_dir, COALESCE(task_id, 0), -state, retained_reason, created_at, finished_at, retained_at, removed_at, removed_by` - -func scanWorktree(row interface{ Scan(...any) error }) (Worktree, error) { - var ( - w Worktree - state, reason, removedBy, created string - finished, retained, removed sql.NullString - ) - if err := row.Scan(&w.ID, &w.Path, &w.WorkDir, &w.Route, &w.Repository, &w.Branch, &w.BaseCommit, &w.OriginatingEventID, &w.BranchCreated, &w.AdminDir, &w.TaskID, - &state, &reason, &created, &finished, &retained, &removed, &removedBy); err != nil { - return Worktree{}, err - } - w.State, w.RetainedReason, w.RemovedBy = WorktreeState(state), RetainedReason(reason), RemovedBy(removedBy) - var err error - if w.CreatedAt, err = parseStamp(created); err != nil { - return Worktree{}, err - } - for _, f := range []struct { - src sql.NullString - dst *time.Time - }{{finished, &w.FinishedAt}, {retained, &w.RetainedAt}, {removed, &w.RemovedAt}} { - if f.src.Valid { - if *f.dst, err = parseStamp(f.src.String); err != nil { - return Worktree{}, err - } - } - } - return w, nil -} - -// ErrWorktreeState is a worktree transition from a state it cannot leave that -// way, or for a row that is not there. -var ErrWorktreeState = errors.New("the worktree is not in a state that allows this") - -// BeginWorktree records a worktree about to be created. Nothing is on disk -// yet. -func (l *Ledger) BeginWorktree(ctx context.Context, w Worktree) (int64, error) { - if w.Path == "" || w.WorkDir == "" || w.Route == "" || w.Repository == "" || w.Branch == "" || w.BaseCommit == "" { - return 0, errors.New("connector: a worktree needs its path, working directory, route, repository, branch and base commit") - } - var id int64 - err := retryBusy(func() error { - res, err := l.db.ExecContext(ctx, ` -INSERT INTO worktrees (path, work_dir, route, repository, branch, base_commit, originating_event_id, state, created_at) -VALUES (?, ?, ?, ?, ?, ?, ?, 'creating', ?)`, - w.Path, w.WorkDir, w.Route, w.Repository, w.Branch, w.BaseCommit, w.OriginatingEventID, l.timestamp()) - if err != nil { - return fmt.Errorf("connector: record worktree %s: %w", w.Path, err) - } - id, err = res.LastInsertId() - return err - }) - return id, err -} - -// WorktreeBranchCreated records that the connector created the task branch -// for a worktree, which is what lets it be deleted again. -func (l *Ledger) WorktreeBranchCreated(ctx context.Context, id int64) error { - return retryBusy(func() error { - _, err := l.db.ExecContext(ctx, `UPDATE worktrees SET branch_created = 1 WHERE id = ?`, id) - if err != nil { - return fmt.Errorf("connector: worktree %d: %w", id, err) - } - return nil - }) -} - -// WorktreeAdminDir records the repository's directory for a worktree. -func (l *Ledger) WorktreeAdminDir(ctx context.Context, id int64, dir string) error { - return retryBusy(func() error { - _, err := l.db.ExecContext(ctx, `UPDATE worktrees SET admin_dir = ? WHERE id = ?`, dir, id) - if err != nil { - return fmt.Errorf("connector: worktree %d: %w", id, err) - } - return nil - }) -} - -// MoveWorktree moves a worktree from one of from to state. It reports -// ErrWorktreeState when the row is in none of them. -func (l *Ledger) MoveWorktree(ctx context.Context, id int64, state WorktreeState, from ...WorktreeState) error { - return l.moveWorktree(ctx, id, state, "", "", from) -} - -// RetainWorktree keeps a worktree, with the reason, from one of from. -func (l *Ledger) RetainWorktree(ctx context.Context, id int64, reason RetainedReason, from ...WorktreeState) error { - if reason == "" { - return errors.New("connector: a retained worktree needs a reason") - } - return l.moveWorktree(ctx, id, WorktreeRetained, reason, "", from) -} - -// RemovedWorktree records a worktree gone, and by whom, from one of from. -func (l *Ledger) RemovedWorktree(ctx context.Context, id int64, by RemovedBy, from ...WorktreeState) error { - if by == "" { - return errors.New("connector: a removed worktree needs who removed it") - } - return l.moveWorktree(ctx, id, WorktreeRemoved, "", by, from) -} - -func (l *Ledger) moveWorktree(ctx context.Context, id int64, state WorktreeState, reason RetainedReason, by RemovedBy, from []WorktreeState) error { - if len(from) == 0 { - return errors.New("connector: a worktree transition names the states it leaves") - } - return retryBusy(func() error { - tx, err := l.db.BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf("connector: begin worktree update: %w", err) - } - defer func() { _ = tx.Rollback() }() - var ( - current, workDir string - finished sql.NullString - ) - switch err := tx.QueryRowContext(ctx, `SELECT state, work_dir, finished_at FROM worktrees WHERE id = ?`, id).Scan(¤t, &workDir, &finished); { - case errors.Is(err, sql.ErrNoRows): - return fmt.Errorf("connector: worktree %d: %w", id, ErrWorktreeState) - case err != nil: - return fmt.Errorf("connector: worktree %d: %w", id, err) - } - allowed := false - for _, f := range from { - allowed = allowed || WorktreeState(current) == f - } - if !allowed { - return fmt.Errorf("connector: worktree %d is %s: %w", id, current, ErrWorktreeState) - } - now := l.timestamp() - // The task that last worked in the directory, for status. - var taskID sql.NullInt64 - if err := tx.QueryRowContext(ctx, `SELECT MAX(id) FROM tasks WHERE work_dir = ?`, workDir).Scan(&taskID); err != nil { - return fmt.Errorf("connector: worktree %d: %w", id, err) - } - switch state { - case WorktreeRetained: - _, err = tx.ExecContext(ctx, ` -UPDATE worktrees SET state = 'retained', retained_reason = ?, retained_at = ?, finished_at = COALESCE(finished_at, ?), - task_id = COALESCE(?, task_id) WHERE id = ?`, string(reason), now, now, taskID, id) - case WorktreeRemoved: - _, err = tx.ExecContext(ctx, ` -UPDATE worktrees SET state = 'removed', removed_by = ?, removed_at = ?, finished_at = COALESCE(finished_at, ?), - task_id = COALESCE(?, task_id) WHERE id = ?`, string(by), now, now, taskID, id) - case WorktreeRemoving: - _, err = tx.ExecContext(ctx, ` -UPDATE worktrees SET state = 'removing', finished_at = COALESCE(finished_at, ?), task_id = COALESCE(?, task_id) WHERE id = ?`, now, taskID, id) - default: - _, err = tx.ExecContext(ctx, `UPDATE worktrees SET state = ? WHERE id = ?`, string(state), id) - } - if err != nil { - return fmt.Errorf("connector: worktree %d to %s: %w", id, state, err) - } - return tx.Commit() - }) -} - -// WorktreeByWorkDir is the open (not removed) worktree a task works in. -func (l *Ledger) WorktreeByWorkDir(ctx context.Context, workDir string) (Worktree, bool, error) { - row := l.db.QueryRowContext(ctx, `SELECT `+worktreeColumns+` FROM worktrees WHERE work_dir = ? AND state <> 'removed'`, workDir) - w, err := scanWorktree(row) - switch { - case errors.Is(err, sql.ErrNoRows): - return Worktree{}, false, nil - case err != nil: - return Worktree{}, false, fmt.Errorf("connector: worktree for %s: %w", workDir, err) - } - return w, true, nil -} - -// Worktrees lists worktrees in the given states, oldest first; every state -// when none is given. -func (l *Ledger) Worktrees(ctx context.Context, states ...WorktreeState) ([]Worktree, error) { - query := `SELECT ` + worktreeColumns + ` FROM worktrees` - var args []any - if len(states) > 0 { - query += ` WHERE state IN (` - for i, s := range states { - if i > 0 { - query += `, ` - } - query += `?` - args = append(args, string(s)) - } - query += `)` - } - rows, err := l.db.QueryContext(ctx, query+` ORDER BY id`, args...) - if err != nil { - return nil, fmt.Errorf("connector: list worktrees: %w", err) - } - defer func() { _ = rows.Close() }() - var out []Worktree - for rows.Next() { - w, err := scanWorktree(rows) - if err != nil { - return nil, fmt.Errorf("connector: list worktrees: %w", err) - } - out = append(out, w) - } - return out, rows.Err() -} - -// RetainedWorktrees are the worktrees kept for a person to deal with. -func (l *Ledger) RetainedWorktrees(ctx context.Context) ([]Worktree, error) { - return l.Worktrees(ctx, WorktreeRetained) -} - -// RetainedWorktreeStatus is the WorktreeLister that status and doctor run: -// the worktrees kept for a person to deal with, as `connect worktrees list` -// counts them — retained, and removing, which a prune is part way through. -// The path, branch, task and reason are the row's; nothing here walks the -// disk, so a status never waits on a worktree that cannot be read. -func (l *Ledger) RetainedWorktreeStatus(ctx context.Context) ([]WorktreeStatus, error) { - records, err := l.Worktrees(ctx, WorktreeRetained, WorktreeRemoving) - if err != nil { - return nil, err - } - out := make([]WorktreeStatus, 0, len(records)) - for _, r := range records { - out = append(out, WorktreeStatus{Path: r.Path, Branch: r.Branch, TaskID: r.TaskID, Reason: string(r.RetainedReason)}) - } - return out, nil -} - -// UnfinishedWorktrees are worktrees a crash left between their creation and -// their task's end: creating, live or removing, with no live task working in -// them. -func (l *Ledger) UnfinishedWorktrees(ctx context.Context) ([]Worktree, error) { - rows, err := l.db.QueryContext(ctx, `SELECT `+worktreeColumns+` FROM worktrees w -WHERE state IN ('creating', 'live', 'removing') - AND NOT EXISTS (SELECT 1 FROM tasks t WHERE t.ended_at IS NULL AND t.work_dir = w.work_dir) -ORDER BY id`) - if err != nil { - return nil, fmt.Errorf("connector: unfinished worktrees: %w", err) - } - defer func() { _ = rows.Close() }() - var out []Worktree - for rows.Next() { - w, err := scanWorktree(rows) - if err != nil { - return nil, fmt.Errorf("connector: unfinished worktrees: %w", err) - } - out = append(out, w) - } - return out, rows.Err() -} diff --git a/internal/connector/lifecycle.go b/internal/connector/lifecycle.go index a8bda72ec..f7ff48f40 100644 --- a/internal/connector/lifecycle.go +++ b/internal/connector/lifecycle.go @@ -12,6 +12,8 @@ import ( "strconv" "strings" "time" + + "github.com/basecamp/basecamp-cli/internal/connector/admission" ) // Lifecycle messages are fixed forms. Every word comes from this file; every @@ -112,23 +114,6 @@ func renderHoldingReply(kind MessageKind, eventID int64) string { return renderLines(kind, lines) } -// renderRefusedStart is the reply to a request whose route no task could be -// given a working directory in. It says the same three things every holding -// reply says: nothing ran, what has to change, and how to run it once it -// has. What has to change is on the connector's machine and not in the -// reader's hands, so the reply names the condition and not the path: a route -// is a directory on someone else's computer, and the card is not where it is -// fixed. -func renderRefusedStart(kind MessageKind, eventID int64) string { - lines := []string{ - "I can't start on this: this project's working directory on the connector's machine is not a git repository with a commit, and the connector is set up to give each task a worktree of its own, so nothing was run.", - "Once the directory is a repository, or the connector runs without worktrees, a person can run it with: " + redispatchAsk(eventID), - "", - "Event " + strconv.FormatInt(eventID, 10) + " · " + lifecycleSignature, - } - return renderLines(kind, lines) -} - // renderRetraction answers an ask an earlier notice made. Both of the things // it claims are checked before it is sent: that a person decided the record at // that time, which the decisions table holds, and that the record is not @@ -187,7 +172,7 @@ func renderRetraction(kind MessageKind, eventID int64, others []int64, postedAt // // The question is asked of the notice that made the ask, not of the record in // general, because "waiting" is not one state. A holding reply answers one -// blocked reason, as its own claim does (holdingReplyReason): a record whose +// blocked reason, as its own claim does: a record whose // rerun replaced no_route with read_failed or throttled is not waiting on a // person at all — those reasons come round again on their own — so that ask // is answered and the record moving between them is the answer. A completion @@ -200,7 +185,7 @@ func askStillOpen(ctx context.Context, q Tx, source Intent, eventID int64) (open switch source.Kind { case IntentHoldingReply: query = `SELECT state = 'blocked' AND reason = ?2 FROM events WHERE id = ?1` - args = append(args, holdingReplyReason(source)) + args = append(args, string(admission.ReasonNoRoute)) case IntentCompletion: query = ` SELECT e.state = 'completed' AND e.redispatch_decision IS NULL @@ -391,53 +376,12 @@ func LifecycleHooks(l *Ledger, opts LifecycleOptions) Hooks { StillRunning: func(ctx context.Context, tx Tx, tick StillRunningTick) error { return stillRunningIntent(ctx, tx, l.now(), tick) }, - StartRefused: func(ctx context.Context, tx Tx, r RefusedStart) error { - return refusedStartIntent(ctx, tx, l.now(), r) - }, RecordDecided: func(ctx context.Context, tx Tx, d RecordDecision) error { return retractionIntents(ctx, tx, l.now(), d) }, } } -// refusedStartIntent is the holding reply for a record the dispatcher -// refused to start. Nothing else would say anything: no attempt was made, so -// no settlement renders a completion notice, and the guard's boost is the -// last thing the recording heard. -// -// A record that asked for no acknowledgement — a subscription, a completion -// — is not a request, and gets no reply, which is the rule the verdict hook -// holds too. -func refusedStartIntent(ctx context.Context, tx Tx, now time.Time, r RefusedStart) error { - var ( - bucketID, replyRecordingID int64 - replyKind string - acknowledge bool - ) - switch err := tx.QueryRowContext(ctx, ` -SELECT bucket_id, reply_kind, reply_recording_id, acknowledge FROM events WHERE id = ?`, r.EventID). - Scan(&bucketID, &replyKind, &replyRecordingID, &acknowledge); { - case errors.Is(err, sql.ErrNoRows): - return fmt.Errorf("connector: lifecycle for event %d: %w", r.EventID, ErrNoSuchRecord) - case err != nil: - return fmt.Errorf("connector: lifecycle for event %d: %w", r.EventID, err) - } - if !acknowledge { - return nil - } - kind, ok := destinationKind(replyKind) - if !ok || replyRecordingID <= 0 { - return nil - } - return writeIntent(ctx, tx, now, newIntent{ - key: refusedStartKey(r.EventID), - kind: IntentHoldingReply, - eventID: r.EventID, - destination: Destination{BucketID: bucketID, Kind: kind, RecordingID: replyRecordingID}, - body: renderRefusedStart(kind, r.EventID), - }) -} - // retractionIntents writes a retraction for every posted notice that asked for // the decision a person has just made. // diff --git a/internal/connector/operator_status_test.go b/internal/connector/operator_status_test.go index 7e89be879..3a328d6cb 100644 --- a/internal/connector/operator_status_test.go +++ b/internal/connector/operator_status_test.go @@ -3,7 +3,6 @@ package connector import ( "context" "encoding/json" - "errors" "os" "path/filepath" "testing" @@ -55,7 +54,7 @@ func TestInvariant8StatusReadsBesideAWriterAndShowsNoSecrets(t *testing.T) { reader, err := OpenLedgerReadOnly(context.Background(), path) require.NoError(t, err) defer func() { _ = reader.Close() }() - s, err := reader.Status(ctx, nil) + s, err := reader.Status(ctx) require.NoError(t, err) require.NotNil(t, s.Hold) @@ -77,7 +76,6 @@ func TestInvariant8StatusReadsBesideAWriterAndShowsNoSecrets(t *testing.T) { require.Len(t, s.Indeterminate, 1) assert.Equal(t, int64(1), s.Indeterminate[0].EventID) require.Len(t, s.Dispatches, 2) - assert.False(t, s.WorktreesKnown) raw, err := json.Marshal(s) require.NoError(t, err) @@ -141,51 +139,6 @@ func TestOpenLedgerRefusesANewerSchema(t *testing.T) { require.ErrorIs(t, err, ErrLedgerSchema) } -// Status reports the retained worktrees the lister gives it. The lister card -// 19's ledger provides reads the same ledger, which holds one connection, so -// a listing made inside status's own read transaction would wait for the -// connection that transaction holds. -func TestStatusListsTheRetainedWorktrees(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - l, err := OpenLedger(filepath.Join(t.TempDir(), "state", LedgerFile)) - require.NoError(t, err) - t.Cleanup(func() { _ = l.Close() }) - - id, err := l.BeginWorktree(ctx, Worktree{ - Path: "/w/one", WorkDir: "/w/one/app", Route: "app", - Repository: "/repo", Branch: "basecamp-connect/1-a1b2c3", BaseCommit: "abc", - }) - require.NoError(t, err) - require.NoError(t, l.MoveWorktree(ctx, id, WorktreeLive, WorktreeCreating)) - require.NoError(t, l.RetainWorktree(ctx, id, RetainedDirty, WorktreeLive)) - - s, err := l.Status(ctx, l.RetainedWorktreeStatus) - require.NoError(t, err) - assert.True(t, s.WorktreesKnown, "a status given a lister says what it knows") - assert.Empty(t, s.WorktreesUnavailable) - require.Len(t, s.Worktrees, 1) - assert.Equal(t, WorktreeStatus{Path: "/w/one", Branch: "basecamp-connect/1-a1b2c3", Reason: "dirty"}, s.Worktrees[0]) -} - -// A listing that fails leaves the retained worktrees unavailable, with why: -// the rest of the status is still worth reading, and "unavailable" is never -// read as "none". -func TestStatusReportsAWorktreeListingThatFailedAsUnavailable(t *testing.T) { - ctx := context.Background() - l, err := OpenLedger(filepath.Join(t.TempDir(), "state", LedgerFile)) - require.NoError(t, err) - t.Cleanup(func() { _ = l.Close() }) - - s, err := l.Status(ctx, func(context.Context) ([]WorktreeStatus, error) { - return nil, errors.New("the worktrees table cannot be read") - }) - require.NoError(t, err, "one unreadable listing does not blank the whole status") - assert.False(t, s.WorktreesKnown) - assert.Contains(t, s.WorktreesUnavailable, "the worktrees table cannot be read") - assert.Empty(t, s.Worktrees) -} - // Every process read back out of the ledger is an identity the one-owner // rule will answer about. The stamp stored is the kernel's — startedStamp // writes no other — so each read-back path marks it exact. A path that @@ -219,13 +172,13 @@ func TestStatusCarriesATokenHolderThatCannotBeAccountedFor(t *testing.T) { launch := launchOf(t, l, 1) require.NoError(t, l.MarkRunning(ctx, launch.AttemptID, AttemptProcess{PID: 4242, PGID: 4242, StartedAt: time.Now(), StartedExact: true})) - before, err := l.Status(ctx, nil) + before, err := l.Status(ctx) require.NoError(t, err) require.Len(t, before.Tasks, 1) assert.False(t, before.Tasks[0].TakerUnaccounted, "nothing has taken the token yet") require.NoError(t, l.MarkTakerUnaccounted(ctx, launch.AttemptID)) - after, err := l.Status(ctx, nil) + after, err := l.Status(ctx) require.NoError(t, err) require.Len(t, after.Tasks, 1) assert.True(t, after.Tasks[0].TakerUnaccounted, "the held attempt says its token is out") diff --git a/internal/connector/outbox.go b/internal/connector/outbox.go index 84b29f46d..6f04c10c5 100644 --- a/internal/connector/outbox.go +++ b/internal/connector/outbox.go @@ -8,8 +8,6 @@ import ( "strconv" "strings" "time" - - "github.com/basecamp/basecamp-cli/internal/connector/admission" ) // The outbox: every message the connector itself posts to Basecamp — the @@ -291,11 +289,7 @@ const ( IntentGuardAck IntentKind = "guard_ack" // IntentHoldingReply answers a request the connector is not going to // start work on until a person changes something: a mention or - // assignment in a project with no route (holdingKey), and one whose - // route no task can be given a working directory in (refusedStartKey). - // One of each per event, because the second can follow the first — a - // route added, and the directory it names not one a worktree can be made - // in — and an event told the first thing is owed the second. + // assignment in a project with no route. One per event. IntentHoldingReply IntentKind = "holding_reply" // IntentStillRunning is one still-running notice. One per attempt and // occurrence. @@ -396,22 +390,6 @@ func holdingKey(eventID int64) string { return string(IntentHoldingReply) + ":event:" + strconv.FormatInt(eventID, 10) } -// refusedStartKey is the holding reply for a record the dispatcher refused -// to start. It is not holdingKey: an event can be told there is no route and -// then, once there is one, that no worktree can be made in it. -func refusedStartKey(eventID int64) string { - return string(IntentHoldingReply) + ":refused:event:" + strconv.FormatInt(eventID, 10) -} - -// holdingReplyReason is the one blocked reason a holding reply answers for: -// its key says which of them it is. -func holdingReplyReason(in Intent) string { - if in.Key == refusedStartKey(in.EventID) { - return ReasonRouteUnusable - } - return string(admission.ReasonNoRoute) -} - func completionKey(attemptID string) string { return string(IntentCompletion) + ":attempt:" + attemptID } diff --git a/internal/connector/outbox_run.go b/internal/connector/outbox_run.go index d6b29832b..e37b3cace 100644 --- a/internal/connector/outbox_run.go +++ b/internal/connector/outbox_run.go @@ -10,6 +10,8 @@ import ( "sync" "time" + "github.com/basecamp/basecamp-cli/internal/connector/admission" + "github.com/basecamp/basecamp-cli/internal/connector/ndjson" ) @@ -413,15 +415,13 @@ func (l *Ledger) claimIntent(ctx context.Context, skip ...int64) (Intent, bool, } } if in.Kind == IntentHoldingReply { - // Each reply answers one reason a record waits on something only - // a person changes, and is sent only while the record is still - // blocked on that reason — not on the other one. Two of them can - // be pending at once (a project with no route, then a route no - // worktree can be made in), and each is wrong about the world the - // moment its own reason is not why the record is waiting. + // The reply answers the one reason a record waits on something + // only a person changes — a project with no route — and is sent + // only while the record is still blocked on it. It is wrong about + // the world the moment that reason is not why the record waits. var stillBlocked bool switch err := tx.QueryRowContext(ctx, `SELECT state = 'blocked' AND reason = ? FROM events WHERE id = ?`, - holdingReplyReason(in), in.EventID).Scan(&stillBlocked); { + string(admission.ReasonNoRoute), in.EventID).Scan(&stillBlocked); { case errors.Is(err, sql.ErrNoRows): // No record, nothing to answer for. Canceled rather than // left to be claimed again on every tick. diff --git a/internal/connector/recovery_connector_test.go b/internal/connector/recovery_connector_test.go index 05923e51c..2b7c30d27 100644 --- a/internal/connector/recovery_connector_test.go +++ b/internal/connector/recovery_connector_test.go @@ -306,11 +306,7 @@ func runHarnessConnector(dir string) error { PrivateDir: filepath.Join(dir, "sessions"), // As the run command wires it: the agent's replies, lifecycle // messages filtered out by the ledger. - Replies: LifecycleFilteredReplies{Lister: storePoster{dir: dir, kill: &killSpec{}}, Ledger: ledger}, - // Not in the run command, which passes none: a task works in its - // route either way. The harness's records when a directory is - // prepared and released, for the one-owner rule's assertions. - Workspaces: &harnessWorkspaces{dir: dir}, + Replies: LifecycleFilteredReplies{Lister: storePoster{dir: dir, kill: &killSpec{}}, Ledger: ledger}, StillRunning: DefaultStillRunning, IsLifecycleMessage: IsLifecycleMessageIn(ledger), Lines: lines, @@ -550,27 +546,6 @@ func recordTaskToken(dir, attemptID, token string) error { return os.WriteFile(filepath.Join(tokens, attemptID+".token"), []byte(token), 0o600) } -// harnessWorkspaces is the working directory a task gets: the route itself, -// as the run command's default does. It records every preparation and every -// release, so a test can say whether a directory was released — which the -// one-owner rule allows only once a worker's process group is gone. -type harnessWorkspaces struct{ dir string } - -type workspaceEvent struct { - Step string `json:"step"` - Route string `json:"route"` - WorkDir string `json:"work_dir"` - EventID int64 `json:"event_id,omitempty"` -} - -func (w *harnessWorkspaces) Prepare(_ context.Context, route string, eventID int64) (string, error) { - return route, appendJSONLine(filepath.Join(w.dir, workspaceFile), workspaceEvent{Step: "prepare", Route: route, WorkDir: route, EventID: eventID}) -} - -func (w *harnessWorkspaces) Finish(_ context.Context, route, workDir string) error { - return appendJSONLine(filepath.Join(w.dir, workspaceFile), workspaceEvent{Step: "finish", Route: route, WorkDir: workDir}) -} - // unsettled says what a run that never reached its predicate was still // holding, so a failure names it rather than the timeout alone. func unsettled(ctx context.Context, l *Ledger) string { diff --git a/internal/connector/recovery_dispatch_test.go b/internal/connector/recovery_dispatch_test.go index 1a5a94738..2970ea7bc 100644 --- a/internal/connector/recovery_dispatch_test.go +++ b/internal/connector/recovery_dispatch_test.go @@ -300,7 +300,7 @@ func (h *harness) assertRecovered(row crashRow) { } assert.Len(t, h.notices(101), row.notices, "completion notices") - status, err := l.Status(context.Background(), nil) + status, err := l.Status(context.Background()) require.NoError(t, err) assert.Len(t, status.Indeterminate, row.indeterminate, "indeterminate lifecycle messages in status") for _, in := range status.Indeterminate { @@ -595,7 +595,6 @@ func TestRecoveryHoldsAnAttemptItCannotIdentify(t *testing.T) { require.Len(t, attempts, 3, "the held attempt, and one for each event in the other project") assert.Equal(t, string(AttemptLaunching), attempts[0].State) assert.Empty(t, attempts[0].StopReason) - assert.False(t, h.releasedDir(h.workDir()), "the held task's working directory is not released") assert.Empty(t, h.notices(101), "an attempt that is still live has no completion to post") }) } @@ -654,7 +653,7 @@ func TestRecoveryTheGuardAcknowledgementIsPostedAtMostOnce(t *testing.T) { assert.Equal(t, GuardAckBody, boost.Content) assert.Equal(t, int64(5001), boost.RecordingID) } - status, err := h.ledger().Status(context.Background(), nil) + status, err := h.ledger().Status(context.Background()) require.NoError(t, err) waiting := 0 for _, in := range status.Indeterminate { @@ -707,14 +706,13 @@ func TestRecoveryAWorkersSurvivingTreeKeepsItsAttempt(t *testing.T) { assert.Equal(t, string(AttemptRunning), attemptState(t, l, attempts[0].id), "the attempt stays live while its tree runs") assert.Equal(t, StateDispatched, stateOf(t, l, 101), "the record is not made terminal") - assert.False(t, h.releasedDir(h.workDir()), "the working directory is not released") assert.Empty(t, h.notices(101), "an attempt that is still live has no completion to post") assert.False(t, processGone(context.Background(), grandchild.PID), "recovery does not signal a group whose leader it cannot verify") _, err := driver.OwnsWorker(worker) assert.ErrorIs(t, err, driver.ErrGroupOutlivedLeader) } - // The tree ends; the next restart may settle and release. + // The tree ends; the next restart may settle. killRecorded(t, grandchild) // Reaped, not merely dead: a zombie is still a member of the group. require.NoError(t, waitFor(ctx, func() (bool, error) { return syscall.Kill(grandchild.PID, 0) != nil, nil })) @@ -722,10 +720,9 @@ func TestRecoveryAWorkersSurvivingTreeKeepsItsAttempt(t *testing.T) { assert.Equal(t, string(AttemptEnded), attemptState(t, l, attempts[0].id)) assert.Equal(t, StateCompleted, stateOf(t, l, 101)) assert.Equal(t, string(OutcomeUnknown), outcomeOf(t, l, 101)) - assert.True(t, h.releasedDir(h.workDir()), "released once the tree is gone") assert.Len(t, h.notices(101), 1) assert.Equal(t, 1, h.handed(101), "and never run again") - assert.Equal(t, 1, h.handed(104), "the directory released, the waiting event runs") + assert.Equal(t, 1, h.handed(104), "the task settled, the waiting event runs") h.assertNoWorkerOutlivedItsRecord() }) } diff --git a/internal/connector/recovery_harness_test.go b/internal/connector/recovery_harness_test.go index 61ab3580a..58cfc9785 100644 --- a/internal/connector/recovery_harness_test.go +++ b/internal/connector/recovery_harness_test.go @@ -363,7 +363,7 @@ func newHarness(t *testing.T, d harnessDriver, sc harnessScenario) *harness { wrapper := "#!/bin/sh\n" + harnessAgentEnv + "=" + shellQuote(d.Name) + " " + harnessDirEnv + "=" + shellQuote(dir) + " exec " + shellQuote(exe) + ` "$@"` + "\n" require.NoError(t, os.WriteFile(h.agent, []byte(wrapper), 0o700)) //nolint:gosec // the fake agent's wrapper must be executable - for _, name := range []string{feedFile, storeFile, linesFile, pollsFile, agentLogFile, liveFile, workspaceFile} { + for _, name := range []string{feedFile, storeFile, linesFile, pollsFile, agentLogFile, liveFile} { require.NoError(t, os.WriteFile(filepath.Join(dir, name), nil, 0o600)) } // Registered before killAgents so it runs after it: the watch for a token @@ -399,14 +399,13 @@ func (h *harness) writeScenario() { // Files in a harness directory. const ( - scenarioFile = "scenario.json" - feedFile = "feed.jsonl" - storeFile = "basecamp.jsonl" - linesFile = "lines.jsonl" - pollsFile = "polls.jsonl" - agentLogFile = "agent.jsonl" - liveFile = "live.jsonl" - workspaceFile = "workspaces.jsonl" + scenarioFile = "scenario.json" + feedFile = "feed.jsonl" + storeFile = "basecamp.jsonl" + linesFile = "lines.jsonl" + pollsFile = "polls.jsonl" + agentLogFile = "agent.jsonl" + liveFile = "live.jsonl" // tokensDir holds the task tokens the fake workers were handed, so the // parent can look for them everywhere a token must not be. tokensDir = "tokens" @@ -793,7 +792,7 @@ func (h *harness) requireNoTaskTokenLeaked(out *lockedBuffer, stateDir string) { return } places.Texts = append(places.Texts, out.String()) - for _, name := range []string{linesFile, storeFile, pollsFile, workspaceFile, agentLogFile} { + for _, name := range []string{linesFile, storeFile, pollsFile, agentLogFile} { data, err := os.ReadFile(filepath.Join(h.dir, name)) require.NoError(t, err) places.Texts = append(places.Texts, string(data)) @@ -1117,32 +1116,6 @@ func runSecretScan(dirs []string) int { return 0 } -// workspaces is every preparation and release of a task's working directory. -func (h *harness) workspaces() []workspaceEvent { - h.t.Helper() - var out []workspaceEvent - require.NoError(h.t, readJSONLines(filepath.Join(h.dir, workspaceFile), func(line []byte) error { - var e workspaceEvent - if err := json.Unmarshal(line, &e); err != nil { - return err - } - out = append(out, e) - return nil - })) - return out -} - -// releasedDir says a task's working directory was handed back, which the -// one-owner rule allows only once its worker's process group is gone. -func (h *harness) releasedDir(dir string) bool { - for _, e := range h.workspaces() { - if e.Step == "finish" && e.WorkDir == dir { - return true - } - } - return false -} - // workDir is the first routed project's working directory. func (h *harness) workDir() string { return filepath.Join(h.dir, "work") } diff --git a/internal/connector/retraction_test.go b/internal/connector/retraction_test.go index f639e6ede..08c8f3b68 100644 --- a/internal/connector/retraction_test.go +++ b/internal/connector/retraction_test.go @@ -5,6 +5,7 @@ import ( "database/sql" "os" "path/filepath" + "strconv" "testing" "time" @@ -130,29 +131,6 @@ func TestPostedAskIsRetractedWhenItIsAnswered(t *testing.T) { assert.Equal(t, IntentSent, obRetraction(t, ledger, holding, 1).State, "a closed record is waiting for nothing") }) - t.Run("a refused start, once the record is redispatched", func(t *testing.T) { - ctx := context.Background() - ledger, clock := obLedger(t) - obAdmit(t, ledger, 1, "recording:10304028989") - require.NoError(t, ledger.RefuseStart(ctx, 1, ReasonRouteUnusable)) - basecamp := newFakeBasecamp(clock.Now) - ob := obOutbox(t, ledger, basecamp) - require.NoError(t, ob.Flush(ctx)) - refusal := obIntent(t, ledger, refusedStartKey(1)) - require.Equal(t, IntentSent, refusal.State) - - clock.Advance(20 * time.Minute) - _, err := ledger.Redispatch(ctx, 1, "jorge") - require.NoError(t, err) - obRoutedNow(t, ledger, 1) - - in := obRetraction(t, ledger, refusal, 1) - assert.Equal(t, refusal.Destination, in.Destination) - assert.Contains(t, in.Body, "A person ran it at 12:20 UTC") - require.NoError(t, ob.Flush(ctx)) - assert.Equal(t, IntentSent, obRetraction(t, ledger, refusal, 1).State) - }) - t.Run("a holding reply in a Campfire, as plain text", func(t *testing.T) { ctx := context.Background() ledger, clock := obLedger(t) @@ -281,7 +259,6 @@ func TestARetractionWaitsWhileTheAskIsStillOpen(t *testing.T) { // posts a wrong message on somebody's card, or leaves a right one unsaid. func TestAskStillOpenReadsWhatTheRecordIsWaitingFor(t *testing.T) { holding := Intent{Kind: IntentHoldingReply, Key: holdingKey(1), EventID: 1} - refused := Intent{Kind: IntentHoldingReply, Key: refusedStartKey(1), EventID: 1} completion := Intent{Kind: IntentCompletion, Key: completionKey("a1")} open := func(t *testing.T, ctx context.Context, ledger *Ledger, source Intent, id int64) bool { t.Helper() @@ -298,7 +275,6 @@ func TestAskStillOpenReadsWhatTheRecordIsWaitingFor(t *testing.T) { _, err := ledger.Admission().Commit(ctx, obNoRouteVerdict(1, 0, obCommentReply)) require.NoError(t, err) assert.True(t, open(t, ctx, ledger, holding, 1)) - assert.False(t, open(t, ctx, ledger, refused, 1), "the route-unusable reply asks about another block") }) t.Run("a blocked reason that comes round on its own answers it", func(t *testing.T) { @@ -841,13 +817,29 @@ func obUpgradedLedger(t *testing.T, clock *obClock, setup func(t *testing.T, old return ledger } +// refusedStartReplyAsItWentOut is the reply a connector with worktrees posted +// for a record whose route was not a repository, in the words it used. The +// renderer is gone with worktrees; the message is still in ledgers that ran +// that build, so the fixture carries the text rather than a stand-in. What +// matters to the retraction is the redispatch ask in it — a notice with no ask +// is a notice with nothing to answer, and postedAsks passes over it. +func refusedStartReplyAsItWentOut(eventID int64) string { + return renderLines(MessageComment, []string{ + "I can't start on this: this project's working directory on the connector's machine is not a git repository with a commit, and the connector is set up to give each task a worktree of its own, so nothing was run.", + "Once the directory is a repository, or the connector runs without worktrees, a person can run it with: " + redispatchAsk(eventID), + "", + "Event " + strconv.FormatInt(eventID, 10) + " · " + lifecycleSignature, + }) +} + // Done when: an upgrade answers the asks it inherits. A build before the -// retraction posted a holding reply and then, once the project had a -// directory that was not a repository, a refused-start reply — two asks for -// one event at one destination, neither of which anything could answer. The -// first decision after the upgrade answers both: an ask left standing under an -// answer to the notice that overtook it still tells a person to run something -// nobody is going to run. +// retraction posted a holding reply and then a second one — the refused-start +// reply a connector with worktrees wrote when the route was not a repository, +// a kind nothing writes any more but an upgraded ledger still carries. Two +// asks for one event at one destination, neither of which anything could +// answer. The first decision after the upgrade answers both: an ask left +// standing under an answer to the notice that overtook it still tells a +// person to run something nobody is going to run. func TestEveryUnansweredAskAtADestinationIsRetracted(t *testing.T) { ctx := context.Background() clock := &obClock{now: time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC)} @@ -865,13 +857,13 @@ VALUES (7, 'holding_reply:event:1', 'holding_reply', 'sent', 1, 48699913 '2026-09-17T11:00:00.000000000Z', '2026-09-17T11:00:00.000000000Z', '2026-09-17T11:01:00.000000000Z', '2026-09-17T11:02:00.000000000Z', 555), (8, 'holding_reply:refused:event:1', 'holding_reply', 'sent', 1, 48699913, 'comment', 10304028989, ?2, '2026-09-17T11:30:00.000000000Z', '2026-09-17T11:30:00.000000000Z', '2026-09-17T11:31:00.000000000Z', '2026-09-17T11:32:00.000000000Z', 556)`, - renderHoldingReply(MessageComment, 1), renderRefusedStart(MessageComment, 1)) + renderHoldingReply(MessageComment, 1), refusedStartReplyAsItWentOut(1)) require.NoError(t, err) }) basecamp := newFakeBasecamp(clock.Now) ob := obOutbox(t, ledger, basecamp) - // A person fixes the repository and redispatches. Deciding the record + // A person routes the project and redispatches. Deciding the record // again while its prerequisite runs answers nothing twice. _, err := ledger.Redispatch(ctx, 1, "jorge") require.NoError(t, err) diff --git a/internal/connector/setup/apply.go b/internal/connector/setup/apply.go index 4c0e28e77..1c8c2b89f 100644 --- a/internal/connector/setup/apply.go +++ b/internal/connector/setup/apply.go @@ -37,8 +37,6 @@ type Changes struct { Worker string Concurrency int Deadline time.Duration - // Worktrees is nil to keep the file's value. - Worktrees *bool } // Apply returns f with ch applied. f is not modified. Everything that can @@ -109,9 +107,6 @@ func Apply(f File, ch Changes) (File, error) { if ch.Deadline != 0 { out.Deadline = Duration(ch.Deadline) } - if ch.Worktrees != nil { - out.Worktrees = *ch.Worktrees - } return out, nil } @@ -182,45 +177,3 @@ func ResolveDir(raw string) (string, error) { } return filepath.Clean(resolved), nil } - -// NoRepositoryAt proves there is no git repository at dir or above it: every -// directory from dir to the filesystem root was read, and none of them holds -// a .git. It is a proof of absence and nothing else — anything that cannot be -// read, a permission error or a path that is not there, is reported as no -// proof, so a directory that is momentarily unreadable is never mistaken for -// one that is not a repository. -// -// It exists because git cannot be asked this question and answered without -// reading its prose: `git rev-parse --show-toplevel` exits 128 both for a -// directory outside a repository and for one it could not enter, and the -// difference between them is the difference between a configuration a person -// has to change and a failure worth retrying. The filesystem is asked -// instead, and the answer is the same one whether it is asked before the -// route is written (RouteChecks) or after a dispatch has failed on it -// (connector's Prepare). -// -// It errs toward no proof in two places, both of which leave the caller -// retrying rather than refusing: a repository git declines to use (dubious -// ownership) has a .git and is not proved absent, and git stops discovery at -// a filesystem boundary while this walk does not. -func NoRepositoryAt(dir string) bool { - info, err := os.Stat(dir) - if err != nil || !info.IsDir() { - return false - } - for dir = filepath.Clean(dir); ; { - switch _, err := os.Lstat(filepath.Join(dir, ".git")); { - case err == nil: - // Something is there. Whatever git makes of it, its refusal is - // not the absence of a repository. - return false - case !errors.Is(err, os.ErrNotExist): - return false - } - parent := filepath.Dir(dir) - if parent == dir { - return true - } - dir = parent - } -} diff --git a/internal/connector/setup/apply_test.go b/internal/connector/setup/apply_test.go index e60c2097d..877358c3d 100644 --- a/internal/connector/setup/apply_test.go +++ b/internal/connector/setup/apply_test.go @@ -17,7 +17,6 @@ import ( func TestApplyKeepsWhatIsNotPassed(t *testing.T) { f := validFile(t) f.Trust = admission.Trust{Mode: admission.TrustAllowlist, OperatorID: operatorID, AllowlistIDs: []int64{7}} - f.Worktrees = true out, err := Apply(f, Changes{}) require.NoError(t, err) @@ -133,13 +132,11 @@ func TestApplyRoutes(t *testing.T) { } func TestApplyDispatchSettings(t *testing.T) { - on := true - out, err := Apply(validFile(t), Changes{Driver: DriverACP, Concurrency: 4, Deadline: 90 * time.Minute, Worktrees: &on}) + out, err := Apply(validFile(t), Changes{Driver: DriverACP, Concurrency: 4, Deadline: 90 * time.Minute}) require.NoError(t, err) assert.Equal(t, DriverACP, out.Driver) assert.Equal(t, 4, out.Concurrency) assert.Equal(t, Duration(90*time.Minute), out.Deadline) - assert.True(t, out.Worktrees) } func TestResolveDirExpandsHome(t *testing.T) { diff --git a/internal/connector/setup/checks.go b/internal/connector/setup/checks.go index 49df8f52f..834cbb1c8 100644 --- a/internal/connector/setup/checks.go +++ b/internal/connector/setup/checks.go @@ -298,9 +298,6 @@ func verifyPerson(ctx context.Context, r Reader, name string, p Person, agentID // RouteChecks reads each routed project the way admission will, as the // agent: the project, and its people (project trust mode's membership read). -// With worktrees on it also asks of each route the one thing a dispatch will -// ask of it that nothing else here does — whether it is in a git repository -// at all — because a route that is not is a route no task ever runs in. func RouteChecks(ctx context.Context, r Reader, f File) []Check { if len(f.Projects) == 0 { return []Check{{ @@ -318,12 +315,12 @@ func RouteChecks(ctx context.Context, r Reader, f File) []Check { checks := make([]Check, 0, len(ids)) for _, id := range ids { - checks = append(checks, routeCheck(ctx, r, f.Agent.Kind, id, f.Projects[id].Path, f.Worktrees)) + checks = append(checks, routeCheck(ctx, r, f.Agent.Kind, id, f.Projects[id].Path)) } return checks } -func routeCheck(ctx context.Context, r Reader, kind string, id int64, path string, worktrees bool) Check { +func routeCheck(ctx context.Context, r Reader, kind string, id int64, path string) Check { c := Check{Name: fmt.Sprintf("Project %d", id)} // The directory is checked as a new route's is, whether the route was // passed now or kept from connect.json: it must still resolve to itself, @@ -334,18 +331,6 @@ func routeCheck(ctx context.Context, r Reader, kind string, id int64, path strin c.Hint = fmt.Sprintf("Route the project again: --route %d=, or remove it: --remove-route %d.", id, id) return c } - // With worktrees on, every task branches from the route's repository. A - // route with no repository takes no worktree, so no task on it ever - // starts: it is refused here rather than one blocked record at a time. - // Only the absence a walk of the disk proves fails the check; a - // repository git might still decline to use is left to the dispatch, - // which reports its refusal. - if worktrees && NoRepositoryAt(path) { - c.Status = StatusFail - c.Message = "Worktrees are on and the route is not in a git repository, so no task on it could start: " + richtext.SanitizeSingleLine(path) - c.Hint = fmt.Sprintf("Route the project to a directory in a repository: --route %d=, or turn worktrees off: --worktrees=false.", id) - return c - } for _, read := range []struct { what string run func(context.Context, int64) error diff --git a/internal/connector/setup/file.go b/internal/connector/setup/file.go index 270269af9..4574c330c 100644 --- a/internal/connector/setup/file.go +++ b/internal/connector/setup/file.go @@ -111,7 +111,16 @@ type File struct { Worker string `json:"worker,omitempty"` Concurrency int `json:"concurrency"` Deadline Duration `json:"deadline"` - Worktrees bool `json:"worktrees"` + + // LegacyWorktrees is the --worktrees setting of a connector that gave + // each task a git worktree of its own. Worktrees are gone: a task runs + // where its route says, and nothing here reads this. It is still a field + // because Parse refuses an unknown key, and a connect.json written + // before they went has "worktrees" in it — accepting it is what lets + // that file still open. Parse zeroes it, and omitempty keeps it out of + // everything written from here, so the next `connect setup` writes the + // key away for good. + LegacyWorktrees bool `json:"worktrees,omitempty"` } // Agent is the identity the agent profile authenticated as at setup. @@ -283,6 +292,8 @@ func Parse(data []byte) (File, error) { if _, err := dec.Token(); !errors.Is(err, io.EOF) { return File{}, errors.New("parse connect.json: trailing data after the object") } + // Read, and forgotten: see LegacyWorktrees. + f.LegacyWorktrees = false if f.Projects == nil { f.Projects = map[int64]admission.Route{} } diff --git a/internal/connector/setup/file_test.go b/internal/connector/setup/file_test.go index f813d90ed..39e9fc6ec 100644 --- a/internal/connector/setup/file_test.go +++ b/internal/connector/setup/file_test.go @@ -87,7 +87,7 @@ func TestSaveWritesTheSpecDefaults(t *testing.T) { assert.Equal(t, "spawn", raw["driver"]) assert.EqualValues(t, 2, raw["concurrency"]) assert.Equal(t, "45m0s", raw["deadline"]) - assert.Equal(t, false, raw["worktrees"]) + assert.NotContains(t, raw, "worktrees", "a setting the connector no longer has is not written") } func TestLoadReportsAMissingFileAsNotExist(t *testing.T) { @@ -116,6 +116,33 @@ func TestParseRefusesUnknownKeys(t *testing.T) { assert.Contains(t, err.Error(), "watch_completion") } +// A connect.json written before worktrees went still opens. The parse is +// strict — an unknown key is a refusal, which is what makes a misspelled +// setting a refusal rather than a silent nothing — so the key every existing +// file carries has to stay known. Deleting the field outright would have +// stopped every connector that had ever been set up. +func TestParseAcceptsAndForgetsTheWorktreesSettingOfAnOlderFile(t *testing.T) { + data, err := json.Marshal(validFile(t)) + require.NoError(t, err) + var raw map[string]any + require.NoError(t, json.Unmarshal(data, &raw)) + require.NotContains(t, raw, "worktrees", "nothing written now carries it") + raw["worktrees"] = true + data, err = json.Marshal(raw) + require.NoError(t, err) + + f, err := Parse(data) + require.NoError(t, err, "a file written by a connector that had worktrees still opens") + assert.False(t, f.LegacyWorktrees, "and the setting is read, then forgotten") + + // And writing that file back takes the key out for good. + again, err := json.Marshal(f) + require.NoError(t, err) + var out map[string]any + require.NoError(t, json.Unmarshal(again, &out)) + assert.NotContains(t, out, "worktrees") +} + func TestValidateFailsClosed(t *testing.T) { for name, mutate := range map[string]func(*File){ "wrong version": func(f *File) { f.Version = 2 }, @@ -186,7 +213,7 @@ func TestParseRefusesDuplicateKeysAndTrailingData(t *testing.T) { "duplicate operator_id": strings.Replace(string(data), `"operator_id":`, `"operator_id":1,"operator_id":`, 1), "duplicate nested route": strings.Replace(string(data), `"48699913":{`, `"48699913":{"path":"/elsewhere",`, 1), "case variant key": strings.Replace(string(data), `"trust":`, `"Trust":`, 1), - "long s variant key": strings.Replace(string(data), `"worktrees"`, `"worktreeſ"`, 1), + "long s variant key": strings.Replace(string(data), `"concurrency"`, `"concurrencſ"`, 1), "padded project id": strings.Replace(string(data), `"48699913":{`, `"048699913":{`, 1), "signed project id": strings.Replace(string(data), `"48699913":{`, `"+48699913":{`, 1), } { diff --git a/internal/connector/setup/route_repository_test.go b/internal/connector/setup/route_repository_test.go deleted file mode 100644 index 433824a59..000000000 --- a/internal/connector/setup/route_repository_test.go +++ /dev/null @@ -1,73 +0,0 @@ -//go:build unix - -package setup - -import ( - "context" - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/basecamp/basecamp-cli/internal/connector/admission" -) - -func TestNoRepositoryAtProvesOnlyWhatItCanRead(t *testing.T) { - dir := t.TempDir() - plain := filepath.Join(dir, "plain") - repo := filepath.Join(dir, "repo", "app") - require.NoError(t, os.MkdirAll(plain, 0o700)) - require.NoError(t, os.MkdirAll(repo, 0o700)) - require.NoError(t, os.MkdirAll(filepath.Join(dir, "repo", ".git"), 0o700)) - - assert.True(t, NoRepositoryAt(plain)) - assert.False(t, NoRepositoryAt(repo), "a .git above the directory is a repository") - assert.False(t, NoRepositoryAt(filepath.Join(dir, "not-there")), "a directory that is not there proves nothing") - - if os.Geteuid() != 0 { - // The other half of the rule, and the reason this file is unix: a - // mode of 0o000 is what makes a directory unsearchable, and it is a - // Unix mode. NoRepositoryAt itself needs no tag — an error that is - // not "does not exist" is no proof whatever produced it — but the - // fixture that sets the state up does. - locked := filepath.Join(dir, "locked") - require.NoError(t, os.MkdirAll(filepath.Join(locked, "route"), 0o700)) - require.NoError(t, os.Chmod(locked, 0o000)) - t.Cleanup(func() { _ = os.Chmod(locked, 0o700) }) - assert.False(t, NoRepositoryAt(filepath.Join(locked, "route")), "a directory that cannot be read proves nothing") - } -} - -// With worktrees on, a route that is not in a repository is a route no task -// ever runs in, and setup says so before it writes connect.json. -func TestRouteChecksRefuseARouteWithNoRepositoryWhenWorktreesAreOn(t *testing.T) { - dir := t.TempDir() - plain := filepath.Join(dir, "plain") - repo := filepath.Join(dir, "repo") - require.NoError(t, os.MkdirAll(plain, 0o700)) - require.NoError(t, os.MkdirAll(filepath.Join(repo, ".git"), 0o700)) - - for _, c := range []struct { - name string - path string - worktrees bool - status string - }{ - {"worktrees on, no repository", plain, true, StatusFail}, - {"worktrees on, a repository", repo, true, StatusPass}, - {"worktrees off, no repository", plain, false, StatusPass}, - } { - t.Run(c.name, func(t *testing.T) { - f := File{Profile: "agent", Worktrees: c.worktrees, Projects: map[int64]admission.Route{1: {Path: c.path}}} - checks := RouteChecks(context.Background(), &fakeReader{}, f) - require.Len(t, checks, 1) - assert.Equal(t, c.status, checks[0].Status, checks[0].Message) - if c.status == StatusFail { - assert.Contains(t, checks[0].Message, "not in a git repository") - assert.Contains(t, checks[0].Hint, "--worktrees=false") - } - }) - } -} diff --git a/internal/connector/worktrees.go b/internal/connector/worktrees.go deleted file mode 100644 index 0f5494362..000000000 --- a/internal/connector/worktrees.go +++ /dev/null @@ -1,2237 +0,0 @@ -package connector - -import ( - "bytes" - "context" - "crypto/rand" - "crypto/sha256" - "encoding/hex" - "errors" - "fmt" - "log/slog" - "os" - "os/exec" - "path/filepath" - "regexp" - "slices" - "strconv" - "strings" - "sync" - "time" - - "github.com/basecamp/basecamp-cli/internal/connector/driver" - "github.com/basecamp/basecamp-cli/internal/connector/setup" -) - -// Worktrees is --worktrees: each task works in a git worktree of its own, -// branched from the route's HEAD, so tasks on one repository run side by -// side. When the task ends its worktree is kept, recorded in the ledger and -// listed by `basecamp connect status` and `basecamp connect worktrees list`, -// until an operator discards it with `basecamp connect worktrees prune`. -// -// # One worktree, one removal -// -// WHEN. Only an operator's `worktrees prune` removes a worktree. The -// connector never removes one of its own accord: a task's end (Finish, which -// the dispatcher calls at its release point, after the task has ended and -// ConfirmGroupGone has confirmed the worker's process group gone) and a -// start's recovery (Recover, before anything is dispatched) only ever keep -// it, whatever is in it. Prune touches only worktrees no live task holds, -// holds the worktrees lock, and goes through removeWorktree. Nothing else in -// the connector deletes a worktree's directory or git's record of it -// (/.git/worktrees/), and nothing runs `git worktree remove`. -// -// A worktree whose directory something outside the connector removed is a -// case of its own: what is left — git's record and the task branch — reaches -// whatever it reaches, and the connector neither judges that nor deletes any -// of it. The row is kept, said to be orphaned, and listed with the record, so -// an operator sees it; naming its path in a force is what deletes the branch, -// and git's own `worktree prune` is what clears the record. Nothing about -// reachability is decided on that path at all. -// -// WHAT is work. Anything on the disk that is not a tracked file, unchanged: -// a modified, staged, untracked or ignored file, a directory git has no file -// in, anything inside a submodule's directory, an index entry that hides an -// edit. An operation in progress (merge, rebase, cherry-pick, revert, -// bisect). A lock someone set. A submodule's git data. And every commit the -// worktree reaches — HEAD, the task branch, their reflogs, per-worktree refs -// (refs/worktree, refs/bisect, refs/rewritten) — that no ref the connector -// keeps holds, a kept ref being a remote branch, a local branch that is not a -// task's, or a ref a forced removal of this worktree kept it under. The commit -// the worktree was made from is one of those commits: the route's branch -// usually holds it, and a route reset since is not evidence that it does. A -// stash -// is in refs/stash, which belongs to the repository and is never touched. -// -// WHAT happens to work. The connector never discards it. A prune without an -// operator's force keeps the worktree, with its reason, and lists it. With -// the force, every commit the worktree reaches that nothing holds is first -// kept under refs/basecamp-connect/retained//; -// a worktree whose work cannot be kept that way (submodule git data, a HEAD -// that cannot be read) is not removed. -// -// HOW the check holds until the removal. removeWorktree freezes the worktree -// before it judges anything: it locks git's record of it (as `git worktree -// lock` does, so git's own prune leaves the frozen record alone), renames the -// record and then the directory aside, each an atomic rename. From then on no git command can -// move its HEAD or commit in it (its .git file names a record that is not -// there), and nothing that reaches it by path can write to it. The evidence -// is judged on the frozen copy, and the frozen copy is what is deleted — or -// both names are restored and the worktree retained. What the judgment leans -// on outside the frozen copy — the refs that hold the commits it reaches — is -// verified again where it was found, in the one ref transaction that ends the -// task branch, immediately before the frozen copy is deleted: a fetch, a -// reset or a deleted branch since makes git refuse the transaction, and the -// worktree is kept instead. A crash while frozen leaves a removing row, and -// the next start restores the names and judges again. The one writer outside the rule is a process that escaped the -// task's process group and holds a descriptor inside the directory. -// -// WHO forces. Only an operator, naming the worktree's path in `basecamp -// connect worktrees prune --force `. A force is a decision about work, -// not a judgment of it: it is the one thing that goes ahead where the rule -// above would keep a worktree, and what it can find is kept under refs first. -// -// # Invariants -// -// Each is held by a test in worktrees_test.go. -// -// 1. The rule above, the first half of it being that a task's end and a -// start's recovery keep every worktree they find. -// 2. The ledger first. A worktree is recorded creating before `git worktree -// add` runs, and removing before it is frozen, so a crash at any point -// leaves a row that says where a directory may be. -// 3. Nothing the repository, its configuration or a worker's files name -// runs: git never looks inside a submodule's directory (the disk is judged -// before git is asked anything that could recurse, and status ignores -// submodules), and every git call runs with hooks, the fsmonitor, -// signature verification and every content filter its configuration -// defines disabled, with a fixed environment. -// 4. A task branch is deleted only if this connector created it, and only in -// one ref transaction that deletes it at the commit judged held and -// verifies that every ref holding a commit the worktree reaches is still -// where the judgment found it. -// -// Placement goes through Options.Path, one function, because under the -// sandbox launcher (step 26) the working directory comes from broker-owned -// scopes instead. -type Worktrees struct { - ledger *Ledger - root string - git string - env []string - path func(root, repository, name string) string - log *slog.Logger - // red takes the connector's own paths and environment out of anything git - // says (the shared rule in driver/redact.go). - red *driver.Redactor - // walkLimit is WalkLimit; a test seam. - walkLimit time.Duration - // whileFrozen runs once a removal has frozen a worktree, before it is - // judged; a test seam. An error leaves it frozen, as a crash would. - whileFrozen func(dir string) error - now func() time.Time - - // Off leaves new tasks in their route; see WorktreesOptions.Off. - off bool - // planOnly is a Worktrees from PlanWorktrees: it has no ledger, and - // everything that would write refuses (ErrPlanOnly). - planOnly bool - - mu sync.Mutex - failures map[string]prepareFailure -} - -var _ WaitingWorkspaces = (*Worktrees)(nil) - -// prepareFailure is a route that could not take a worktree, and when to try -// it again. -type prepareFailure struct { - count int - until time.Time -} - -// Prepare's backoff after a failure: doubling from the first, capped. -const ( - PrepareBackoff = time.Minute - PrepareBackoffMax = 30 * time.Minute -) - -// ErrPrepareBackoff is a Prepare on a route whose last worktree failed too -// recently to try again. -var ErrPrepareBackoff = errors.New("the last worktree on this route failed; waiting before trying again") - -// ErrRouteUnusable is a Prepare that failed on what the route is, rather -// than on something that happened while it was read: there is no repository -// at or above it, it is not inside the repository git named, or that -// repository has no commit to branch from. Waiting changes none of those, so -// a Prepare that wraps it is not backed off and tried again — the dispatcher -// refuses the record, names the reason in the ledger and answers the -// recording that asked. -// -// The distinction is proved, never read out of git's words: absence of a -// repository by walking the disk (setup.NoRepositoryAt), absence of a commit -// by the exit status rev-parse --quiet keeps for a name that is valid and -// resolves to nothing. Anything a proof does not cover — git missing, a -// directory that could not be entered, a timeout — is left as it was and -// retried, because a route that is momentarily unreadable is not a route -// that is misconfigured. -var ErrRouteUnusable = errors.New("no worktree can be made on this route until its directory is changed") - -// unusable marks err as ErrRouteUnusable when the cause is proved to be the -// route itself, and leaves it alone otherwise. -func unusable(proved bool, err error) error { - if !proved { - return err - } - return fmt.Errorf("%w: %w", err, ErrRouteUnusable) -} - -// WorktreesOptions configures Worktrees. -type WorktreesOptions struct { - Ledger *Ledger - // Root is the owner-only directory worktrees are placed under: the - // connector state directory's worktrees/. - Root string - // Git is the git binary; "git" on PATH when empty. - Git string - // Lookup reads the connector's environment for git's; os.LookupEnv when - // nil. - Lookup func(string) (string, bool) - // Redaction is what is taken out of anything git says: the driver - // package's shared rule. - Redaction driver.Redaction - // Path places a task's worktree; DefaultWorktreePath when nil. - Path func(root, repository, name string) string - Logger *slog.Logger - // Off gives new tasks no worktree: they work in the route itself. The - // worktrees made while it was on are still settled and recovered, so - // switching worktrees off never strands one. - Off bool -} - -var ( - _ PerTaskWorkspaces = (*Worktrees)(nil) - _ RecoveringWorkspaces = (*Worktrees)(nil) -) - -// BranchPrefix names every task branch, so a task branch is never evidence -// that another task's commits are safe. -const BranchPrefix = "basecamp-connect/" - -// ErrPlanOnly is anything that would make, record or remove a worktree -// asked of a Worktrees built by PlanWorktrees. -var ErrPlanOnly = errors.New("connector: these worktrees can only be planned, not made") - -// NewWorktrees builds Worktrees. -func NewWorktrees(opts WorktreesOptions) (*Worktrees, error) { - if opts.Ledger == nil { - return nil, errors.New("connector: worktrees need the ledger and an absolute root") - } - return newWorktrees(opts) -} - -// ErrNoRoot is worktrees asked for without a directory to put them under. -var ErrNoRoot = errors.New("connector: worktrees need an absolute root") - -// PlanWorktrees builds a Worktrees that only answers Plan: where a task on a -// route would work, and what its checkout would hold. It takes no ledger -// because it records nothing and makes nothing — Prepare, Finish, Recover -// and Prune on it return ErrPlanOnly — and it is the same type as the one -// that does the work on purpose: doctor asks where a dispatch would run by -// running the code that decides it, not a second copy of the rule. -func PlanWorktrees(opts WorktreesOptions) (*Worktrees, error) { - if opts.Off { - // With worktrees off a task works in its route, which is not a - // decision to plan: a planner that answered anyway would answer - // something Prepare would not do, which is the one thing planning - // exists to rule out. - return nil, ErrPlanNothing - } - w, err := newWorktrees(opts) - if err != nil { - return nil, err - } - w.planOnly = true - return w, nil -} - -// ErrPlanNothing is a planner asked for with worktrees off: a task's working -// directory is then its route, and nothing about it is planned. -var ErrPlanNothing = errors.New("connector: with worktrees off a task works in its route; there is nothing to plan") - -func newWorktrees(opts WorktreesOptions) (*Worktrees, error) { - if opts.Root == "" || !filepath.IsAbs(opts.Root) { - return nil, ErrNoRoot - } - if opts.Git == "" { - opts.Git = "git" - } - if opts.Lookup == nil { - opts.Lookup = os.LookupEnv - } - if opts.Path == nil { - opts.Path = DefaultWorktreePath - } - if opts.Logger == nil { - opts.Logger = slog.New(slog.DiscardHandler) - } - env := driver.BuildEnv(driver.BaseEnv, opts.Lookup, map[string]string{ - // Never ask anyone anything, never take an optional lock a person's - // own git in the checkout would then wait on. - "GIT_TERMINAL_PROMPT": "0", - "GIT_OPTIONAL_LOCKS": "0", - "LC_ALL": "C", - }) - return &Worktrees{ - ledger: opts.Ledger, root: opts.Root, git: opts.Git, env: env, path: opts.Path, log: opts.Logger, - now: time.Now, off: opts.Off, walkLimit: WalkLimit, - red: driver.NewRedactor(opts.Redaction.With(driver.Redaction{Env: env, Dirs: []string{opts.Root}})), failures: map[string]prepareFailure{}, - }, nil -} - -// DefaultWorktreePath places a worktree under the connector's state -// directory, one directory per repository: never inside the checkout, where a -// task working in the route itself could edit another task's retained work, -// and `git add -A` in the checkout would pick it up. -func DefaultWorktreePath(root, repository, name string) string { - sum := sha256.Sum256([]byte(repository)) - return filepath.Join(root, safeName(filepath.Base(repository))+"-"+hex.EncodeToString(sum[:4]), name) -} - -var unsafeNameRunes = regexp.MustCompile(`[^A-Za-z0-9._-]+`) - -func safeName(s string) string { - s = unsafeNameRunes.ReplaceAllString(s, "-") - s = strings.Trim(s, ".-") - if len(s) > 40 { - s = s[:40] - } - if s == "" { - return "repo" - } - return s -} - -// PerTaskDirs implements PerTaskWorkspaces. -func (w *Worktrees) PerTaskDirs() bool { return !w.off } - -// Prepare implements Workspaces: a new worktree on a new task branch at the -// route's HEAD, and the route's place inside it. -// -// A failure holds the route, not the event: what stops a worktree (a full -// disk, a git that will not run, a directory that cannot be read) stops -// every event on it. The route waits PrepareBackoff, doubling up to -// PrepareBackoffMax, and RoutesWaiting tells the dispatcher to leave its -// records out, so they neither fill the disk and the ledger nor the window -// other routes' records are started from. -// -// A failure the route itself is (ErrRouteUnusable: no repository, no commit) -// is not waited on. Waiting is for a condition that may pass, and this one -// passes only when a person changes the directory; the error is returned -// with no backoff armed, and the dispatcher refuses the record rather than -// leaving the route to back off in silence. -func (w *Worktrees) Prepare(ctx context.Context, route string, originatingEventID int64) (string, error) { - if w.planOnly { - return "", ErrPlanOnly - } - if w.off { - return route, nil - } - w.mu.Lock() - failure, failed := w.failures[route] - w.mu.Unlock() - if failed && w.now().Before(failure.until) { - return "", fmt.Errorf("connector: event %d on %s: %w", originatingEventID, route, ErrPrepareBackoff) - } - workDir, err := w.prepare(ctx, route, originatingEventID) - w.mu.Lock() - defer w.mu.Unlock() - if errors.Is(err, ErrRouteUnusable) { - // Not a wait. A backoff would hold every other record on the route - // out of the dispatcher's window and answer one of them every half - // hour at most; the route is not going to become a repository on its - // own, so each record is refused as it comes and told once. - delete(w.failures, route) - return "", err - } - if err != nil { - failure.count++ - delay := PrepareBackoff << min(failure.count-1, 10) - failure.until = w.now().Add(min(delay, PrepareBackoffMax)) - w.failures[route] = failure - return "", err - } - delete(w.failures, route) - return workDir, nil -} - -// RoutesWaiting implements WaitingWorkspaces: the routes still in a Prepare -// backoff. -func (w *Worktrees) RoutesWaiting() []string { - w.mu.Lock() - defer w.mu.Unlock() - now := w.now() - var out []string - for route, f := range w.failures { - if now.Before(f.until) { - out = append(out, route) - } - } - return out -} - -// PlannedWorkDir is where one task on a route would work, decided without -// anything being made: the answer Prepare acts on, and the answer anything -// that has to agree with a dispatch — doctor — asks for instead of guessing. -type PlannedWorkDir struct { - // Route is the approved directory the project maps to. - Route string - // Repository is the repository the route is in. - Repository string - // Rel is the route's own place inside it, and so inside the worktree. - Rel string - // Worktree is where the checkout would be added. - Worktree string - // Dir is the session's working directory: the route's place in the - // worktree, which is what the driver is given as its cwd. - Dir string - // BaseCommit is the commit the worktree would be made at, and so the - // content its checkout would hold. - BaseCommit string - - w *Worktrees -} - -// Plan is where a task named name on route would work: the same decision -// Prepare makes, with none of its effects. It runs the three reads Prepare -// runs — the repository the route is in, the route's place inside it, and -// the commit a worktree would be made at — and fails where Prepare fails, -// with the same words, so a caller that plans learns now what a dispatch -// would learn later. -func (w *Worktrees) Plan(ctx context.Context, route, name string) (*PlannedWorkDir, error) { - if !filepath.IsAbs(route) { - return nil, fmt.Errorf("connector: route %q is not absolute", route) - } - top, err := w.gitOut(ctx, route, "rev-parse", "--show-toplevel") - if err != nil { - // Proved, never read out of git's words (#753): the route itself - // being unusable is not a condition that passes, and a caller that - // plans — doctor — says so rather than waiting for it. - return nil, unusable(setup.NoRepositoryAt(route), fmt.Errorf("connector: route %s is not in a git repository: %w", route, err)) - } - repository := filepath.Clean(top) - rel, err := filepath.Rel(realPath(repository), realPath(route)) - if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { - return nil, fmt.Errorf("connector: route %s is not inside its repository: %w", route, ErrRouteUnusable) - } - base, err := w.gitOut(ctx, repository, "rev-parse", "--verify", "--end-of-options", "HEAD^{commit}") - if err != nil { - return nil, unusable(w.hasNoCommit(ctx, repository), fmt.Errorf("connector: route %s has no commit to branch from: %w", route, err)) - } - if err := w.inCheckout(ctx, repository, base, rel); err != nil { - return nil, fmt.Errorf("connector: route %s: %w", route, err) - } - path := w.path(w.root, repository, name) - if !filepath.IsAbs(path) { - return nil, fmt.Errorf("connector: worktree path %q is not absolute", path) - } - return &PlannedWorkDir{ - Route: route, Repository: repository, Rel: rel, - Worktree: path, Dir: filepath.Join(path, rel), BaseCommit: base, w: w, - }, nil -} - -// git's mode for a tree entry, as ls-tree prints it. -const ( - modeTree = "040000" - modeSymlink = "120000" - modeGitlink = "160000" -) - -// ErrNotModeled is a path a planned read does not model: the commit has -// something there, but not a regular file whose content this can read. -var ErrNotModeled = errors.New("connector: a planned read models a regular file and nothing else") - -// UnmodeledPathError is one such path, so a caller can say which layer it -// could not check and what is in the way. -type UnmodeledPathError struct { - // Path is the path in the worktree, as it was asked for. - Path string - // Kind is what the repository has there: a symbolic link, a submodule. - Kind string - // Repository and Commit are where it was looked for. - Repository string - Commit string -} - -func (e *UnmodeledPathError) Error() string { - return fmt.Sprintf("%s is %s in %s at %s, which a planned read does not model", e.Path, e.Kind, e.Repository, shortCommit(e.Commit)) -} - -func (e *UnmodeledPathError) Unwrap() error { return ErrNotModeled } - -// inCheckout refuses a route the checkout would not contain. A worktree is -// the commit's tree written out, and git writes a directory only where the -// tree has one: a route that is untracked or ignored — or that the commit -// has as a file — is a working directory that would not be there, and -// nothing can be started in it. The tree is asked, and only what it proves -// is refused (ErrRouteUnusable, which arms no backoff and is the record's -// reason): a submodule is a directory the checkout makes, empty, and a -// symbolic link is followed at dispatch to wherever it points, which this -// does not guess at either way. -func (w *Worktrees) inCheckout(ctx context.Context, repository, commit, rel string) error { - if rel == "." { - return nil - } - mode, err := w.treeEntry(ctx, repository, commit, filepath.ToSlash(rel)) - switch { - case err != nil: - // Not a proof of anything: the next attempt asks again. - return err - case mode == "": - return fmt.Errorf("%s is not a directory %s tracks at %s, so no worktree of it would hold the route: %w", - rel, repository, shortCommit(commit), ErrRouteUnusable) - case mode == modeTree, mode == modeGitlink, mode == modeSymlink: - return nil - default: - return fmt.Errorf("%s is a file in %s at %s, not a directory a session could run in: %w", - rel, repository, shortCommit(commit), ErrRouteUnusable) - } -} - -// ReadFile reads a file as the planned session would read it, as far as it -// can be said without a checkout to read. -// -// The promise, exactly: a path inside the worktree is what the repository -// tracks at BaseCommit as a regular file, which is what git would write -// into the checkout. A file only the route has — untracked, ignored, or -// edited since the commit — is in no worktree and reads as absent. -// Everything outside the worktree is read from disk, where the session -// reads it. -// -// What it does not promise: anything the repository has there that is not a -// regular file. A symbolic link is followed by the kernel at dispatch to -// wherever it points, inside the tree or out of it, and git hands back its -// target rather than that file; a submodule's directory is left empty by -// the checkout, and whether the session then reads something there depends -// on what a person did to it. Modeling those means modeling git, one -// object kind at a time, and this does not: it returns ErrNotModeled — -// an UnmodeledPathError naming the path and what is in the way — so the -// caller can say it did not check that layer. What must never happen is a -// profile called ready on the strength of a layer nothing read, which is -// the bug this whole check exists for. -// -// A path the commit does not have is os.ErrNotExist, as an absent file is. -// A directory where a file has to be, or a file where a directory has to -// be, is an error, as it is on a filesystem. -func (p *PlannedWorkDir) ReadFile(ctx context.Context, name string) ([]byte, error) { - rel, inside := p.inWorktree(name) - if !inside { - return os.ReadFile(name) //nolint:gosec // G304: the caller's own configuration paths - } - return p.readInWorktree(ctx, name, rel) -} - -// inWorktree is name's place inside the planned worktree, slash-separated, -// and whether it is inside it at all. The worktree itself is "". -func (p *PlannedWorkDir) inWorktree(name string) (string, bool) { - rel, err := filepath.Rel(p.Worktree, name) - if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) { - return "", false - } - if rel == "." { - return "", true - } - return filepath.ToSlash(rel), true -} - -// readInWorktree walks rel one component at a time, so a link or a -// submodule on the way is named rather than walked through. -func (p *PlannedWorkDir) readInWorktree(ctx context.Context, name, rel string) ([]byte, error) { - if rel == "" { - return nil, fmt.Errorf("connector: %s is the worktree itself, not a file in it", p.Worktree) - } - parts := strings.Split(rel, "/") - for i, part := range parts { - if part == "" || part == "." || part == ".." { - return nil, fmt.Errorf("connector: %q is not a path in a worktree", rel) - } - here := strings.Join(parts[:i+1], "/") - last := i == len(parts)-1 - mode, err := p.w.treeEntry(ctx, p.Repository, p.BaseCommit, here) - switch { - case err != nil: - return nil, err - case mode == "": - return nil, fmt.Errorf("%s at %s: %w", here, shortCommit(p.BaseCommit), os.ErrNotExist) - case mode == modeSymlink, mode == modeGitlink: - kind := "a symbolic link" - if mode == modeGitlink { - kind = "a submodule" - } - return nil, &UnmodeledPathError{ - Path: filepath.Join(p.Worktree, filepath.FromSlash(here)), Kind: kind, - Repository: p.Repository, Commit: p.BaseCommit, - } - case mode == modeTree: - if last { - return nil, fmt.Errorf("connector: %s in a worktree of %s is a directory", name, p.Repository) - } - case !last: - return nil, fmt.Errorf("connector: %s in a worktree of %s is a file, not a directory", - filepath.Join(p.Worktree, filepath.FromSlash(here)), p.Repository) - default: - return p.w.blobAtCommit(ctx, p.Repository, p.BaseCommit, here) - } - } - // Every component but the last is a tree, and the last returned above. - return nil, fmt.Errorf("connector: %q could not be resolved in a worktree of %s", rel, p.Repository) -} - -// treeEntry is the mode of one path in a commit's tree, and "" when the -// commit has no such path. Existence is asked of the tree rather than read -// from an error's words, and the path is given as a literal pathspec, so a -// directory named for a glob is the directory it is named for. -func (w *Worktrees) treeEntry(ctx context.Context, repository, commit, rel string) (string, error) { - if rel == "" || strings.ContainsRune(rel, 0) { - return "", fmt.Errorf("connector: %q is not a path in a repository", rel) - } - listed, err := w.gitOut(ctx, repository, "ls-tree", "--full-tree", "-z", "--end-of-options", commit, "--", ":(literal)"+rel) - if err != nil { - return "", err - } - entry, _, _ := strings.Cut(strings.Trim(listed, "\x00"), "\t") - if entry == "" { - return "", nil - } - mode, _, _ := strings.Cut(entry, " ") - return mode, nil -} - -// blobAtCommit is the content of one regular file in a commit's tree. -func (w *Worktrees) blobAtCommit(ctx context.Context, repository, commit, rel string) ([]byte, error) { - return w.gitRaw(ctx, repository, "show", "--end-of-options", commit+":"+rel) -} - -// shortCommit is a commit id as a person reads one. -func shortCommit(commit string) string { - return commit[:min(len(commit), 12)] -} - -func (w *Worktrees) prepare(ctx context.Context, route string, originatingEventID int64) (string, error) { - if !filepath.IsAbs(route) { - return "", fmt.Errorf("connector: route %q is not absolute", route) - } - suffix := make([]byte, 3) - if _, err := rand.Read(suffix); err != nil { - return "", err - } - name := strconv.FormatInt(originatingEventID, 10) + "-" + hex.EncodeToString(suffix) - plan, err := w.Plan(ctx, route, name) - if err != nil { - return "", err - } - record := Worktree{ - Path: plan.Worktree, WorkDir: plan.Dir, Route: route, Repository: plan.Repository, - Branch: BranchPrefix + name, BaseCommit: plan.BaseCommit, OriginatingEventID: originatingEventID, - State: WorktreeCreating, - } - id, err := w.ledger.BeginWorktree(ctx, record) - if err != nil { - return "", err - } - record.ID = id - - err = w.add(ctx, &record) - if err == nil { - record.AdminDir, err = w.gitOut(ctx, record.Path, "rev-parse", "--absolute-git-dir") - } - if err == nil { - err = w.ledger.WorktreeAdminDir(ctx, id, record.AdminDir) - } - if err == nil { - err = w.ledger.MoveWorktree(ctx, id, WorktreeLive, WorktreeCreating) - } - if err != nil { - // Whatever git left is judged like any finished worktree; a lock not - // had leaves the row for the next start. - settleCtx := context.WithoutCancel(ctx) - if unlock, lockErr := w.lock(settleCtx); lockErr == nil { - w.settle(settleCtx, record) - unlock() - } - return "", fmt.Errorf("connector: create a worktree for event %d: %w", originatingEventID, err) - } - return plan.Dir, nil -} - -// hasNoCommit proves a repository has no commit HEAD reaches. rev-parse -// --quiet exits 1 for a name that is valid and resolves to nothing, which is -// exactly an empty repository, and 128 for everything that went wrong on the -// way there; anything but the 1 proves nothing and the failure stays one to -// retry. -func (w *Worktrees) hasNoCommit(ctx context.Context, repository string) bool { - _, err := w.gitOut(ctx, repository, "rev-parse", "--quiet", "--verify", "--end-of-options", "HEAD^{commit}") - var exit *exec.ExitError - return errors.As(err, &exit) && exit.ExitCode() == 1 -} - -func (w *Worktrees) add(ctx context.Context, r *Worktree) error { - if err := os.MkdirAll(w.root, 0o700); err != nil { - return err - } - if err := setup.EnsurePrivateDir(filepath.Dir(r.Path)); err != nil { - return err - } - // The branch is created before the worktree and only if it does not - // exist, so the row's branch is this task's and deleting it later can - // never delete a branch someone else made (invariant 1). - if _, err := w.gitOut(ctx, r.Repository, "update-ref", "--end-of-options", "refs/heads/"+r.Branch, r.BaseCommit, ""); err != nil { - return err - } - if err := w.ledger.WorktreeBranchCreated(ctx, r.ID); err != nil { - return err - } - // The caller settles this record if anything below fails, and only a - // record that says the branch is ours lets it be deleted again. - r.BranchCreated = true - // The checkout runs in the new worktree, so the filters blanked are the - // ones its own configuration defines (an include on its branch among - // them), not the checkout's the route is in. - if _, err := w.gitOut(ctx, r.Repository, "worktree", "add", "--no-checkout", "--end-of-options", r.Path, r.Branch); err != nil { - return err - } - _, err := w.gitOut(ctx, r.Path, "reset", "--quiet", "--hard", "--end-of-options", r.BaseCommit) - return err -} - -// Finish implements Workspaces: the worktree a task worked in is kept, -// whatever is in it, and recorded as kept so `worktrees list` shows it and a -// prune can judge it. Nothing here removes anything. A directory that is not -// one of this connector's worktrees is left alone. -func (w *Worktrees) Finish(ctx context.Context, _ string, workDir string) error { - if w.planOnly { - return ErrPlanOnly - } - record, ok, err := w.ledger.WorktreeByWorkDir(ctx, workDir) - if err != nil || !ok { - return err - } - if record.State != WorktreeCreating && record.State != WorktreeLive { - return nil - } - unlock, err := w.lock(ctx) - if err != nil { - // The worktree is kept either way, but it is said to be kept: a row - // left live is a directory `worktrees list` does not show and no - // prune touches until the next start settles it. - return errors.Join(err, w.keepUnjudged(ctx, record)) - } - defer unlock() - if after := w.settle(ctx, record); after.State != WorktreeRetained && after.State != WorktreeRemoved { - // The worktree is where it was; the ledger could not say so, and the - // row is not one `worktrees list` shows or a prune touches. The next - // start settles it. - return fmt.Errorf("connector: worktree %s is kept, but the ledger could not record it; the next start does", record.Path) - } - return nil -} - -// keepUnjudged retains a worktree the connector could not judge, so the -// operator sees it in `worktrees list` and a prune judges it later. -func (w *Worktrees) keepUnjudged(ctx context.Context, r Worktree) error { - // Waiting for the lock is what used the caller's deadline up: the row is - // still recorded, on a deadline of its own. - ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) - defer cancel() - if err := w.ledger.RetainWorktree(ctx, r.ID, RetainedUnverified, r.State); err != nil { - return fmt.Errorf("connector: worktree %s is kept, but the ledger could not record it; the next start does: %w", r.Path, err) - } - w.log.Info("connector: worktree retained", "path", r.Path, "branch", r.Branch, "reason", string(RetainedUnverified)) - return nil -} - -// Recover implements RecoveringWorkspaces: every worktree a crash left -// creating, live or removing with no live task in it is kept and recorded as -// kept, as a finished task's is, after a removal the crash interrupted has -// its names restored. It removes nothing. It runs in the connector that holds -// the instance lock, before anything is dispatched. -func (w *Worktrees) Recover(ctx context.Context) error { - if w.planOnly { - return ErrPlanOnly - } - unlock, err := w.lock(ctx) - if errors.Is(err, context.DeadlineExceeded) && ctx.Err() == nil { - // A prune holding the lock does not keep the connector from starting: - // what Recover would settle is no task's, nothing is dispatched into - // it, and the next start settles it. - w.log.Warn("connector: worktrees are locked by another process; recovery left for the next start") - return nil - } - if err != nil { - return err - } - defer unlock() - records, err := w.ledger.UnfinishedWorktrees(ctx) - if err != nil { - return err - } - var unrecorded []string - for _, r := range records { - if after := w.settle(ctx, r); after.State != WorktreeRetained && after.State != WorktreeRemoved { - unrecorded = append(unrecorded, r.Path) - } - } - if len(unrecorded) > 0 { - // Nothing was deleted — recovery deletes nothing — but the ledger - // does not say where these worktrees are, so nothing lists them and - // no prune touches them. Starting on that is starting blind. - return fmt.Errorf("connector: %d worktree(s) could not be recorded: %s", len(unrecorded), strings.Join(unrecorded, ", ")) - } - return nil -} - -// Retained lists the worktrees kept for the operator: those retained, and -// those a removal left mid-flight, which hold work until a start or a prune -// judges them again. -func (w *Worktrees) Retained(ctx context.Context) ([]Worktree, error) { - if w.planOnly { - return nil, ErrPlanOnly - } - return w.ledger.Worktrees(ctx, WorktreeRetained, WorktreeRemoving) -} - -// PruneAction is what prune did with one retained worktree. -type PruneAction string - -const ( - PruneRemoved PruneAction = "removed" - PruneForced PruneAction = "forced" - PruneMissing PruneAction = "missing" - PruneKept PruneAction = "kept" -) - -// PruneResult is one retained worktree after prune. -type PruneResult struct { - Worktree Worktree - Action PruneAction - // Reason is why a kept worktree was kept. - Reason RetainedReason - // ForceRefused is a --force that could not go through: the worktree's - // work could not be kept by refs. - ForceRefused bool - // RetainedRefs are the refs a forced removal kept commits under. - RetainedRefs []string - // BranchDeletedAt is the commit the task branch stood at when a force on - // an orphaned worktree deleted it. Nothing judged what that branch - // reached, so this is what an operator needs to put it back - // (`git branch `) before git's own prune clears the - // record. - BranchDeletedAt string -} - -// RetainedRefPrefix names the refs a forced removal keeps commits under: the -// operator is told about each one, and nothing here deletes them. -const RetainedRefPrefix = "refs/basecamp-connect/retained/" - -// RemovingRefPrefix names the refs a removal holds a worktree's commits under -// while it deletes it. They are the connector's own bookkeeping, let go when -// the removal is over, and never counted as holding a commit for anybody: one -// a crash left behind holds its commits without making the next judgment -// think someone else does. -const RemovingRefPrefix = "refs/basecamp-connect/removing/" - -// ErrNotRetained is a --force naming a path that is no retained worktree. -var ErrNotRetained = errors.New("not a retained worktree") - -// Prune removes the retained worktrees the operator has dealt with: those now -// clean, with every commit they reach held elsewhere. It is the only thing -// that removes a worktree. One still holding work is kept unless its path is -// in force, and so is one whose directory something else removed — that row -// is kept as orphaned, with git's record and the task branch left as they -// are, until its path is in force. A path in force that is no retained -// worktree refuses the whole prune before anything is removed. -func (w *Worktrees) Prune(ctx context.Context, force []string) ([]PruneResult, error) { - if w.planOnly { - return nil, ErrPlanOnly - } - unlock, err := w.lock(ctx) - if err != nil { - return nil, err - } - defer unlock() - records, err := w.ledger.Worktrees(ctx, WorktreeRetained, WorktreeRemoving) - if err != nil { - return nil, err - } - forced := map[string]bool{} - for _, p := range force { - clean := filepath.Clean(p) - if !slices.ContainsFunc(records, func(r Worktree) bool { return r.Path == clean }) { - return nil, fmt.Errorf("connector: %s: %w", p, ErrNotRetained) - } - forced[clean] = true - } - out := make([]PruneResult, 0, len(records)) - for _, r := range records { - out = append(out, w.pruneOne(ctx, r, forced[r.Path])) - } - return out, nil -} - -func (w *Worktrees) pruneOne(ctx context.Context, r Worktree, force bool) PruneResult { - var refs []string - by := RemovedByPrune - if force { - by = RemovedByPruneForced - } - var at string - after := w.settleKeeping(ctx, r, by, force, &refs, &at) - result := PruneResult{Worktree: after, RetainedRefs: refs, BranchDeletedAt: at} - gone := after.State == WorktreeRemoving && !exists(after.Path) && !exists(frozenName(after.Path)) - switch { - case force && (after.State == WorktreeRemoved || gone): - result.Action = PruneForced - case after.State == WorktreeRemoved && after.RemovedBy == RemovedMissing: - result.Action = PruneMissing - case after.State == WorktreeRemoved || gone: - // Removing and gone is a removal the ledger could not record yet. - result.Action = PruneRemoved - default: - result.Action, result.Reason = PruneKept, after.RetainedReason - // A moved worktree has nothing here to force; anything else kept - // under a force is a force refused. - result.ForceRefused = force && after.RetainedReason != RetainedMoved - } - return result -} - -// settle is what the connector does with a worktree of its own accord, at the -// end of a task and at recovery: it keeps it. Nothing the connector does -// removes a worktree — only an operator's `worktrees prune` does — so this -// restores a removal a crash left frozen, reconciles a row whose directory is -// no longer there, and otherwise retains the row for the operator. The caller -// holds the lock. It returns the row as it now stands. -func (w *Worktrees) settle(ctx context.Context, r Worktree) Worktree { - from := []WorktreeState{r.State} - if restored, ok := w.unfreeze(r); !ok { - w.log.Warn("connector: a frozen worktree could not be restored; kept", "path", r.Path) - return w.retain(ctx, r, RetainedUnverified, from) - } else if restored { - w.log.Info("connector: restored a worktree a removal left frozen", "path", r.Path) - } - if !exists(r.Path) { - return w.forget(ctx, r, from, nil, nil) - } - return w.retain(ctx, r, RetainedFinished, from) -} - -// forget reconciles a row whose worktree is not on disk: something outside -// the connector removed the directory. What is left is git's record of the -// worktree and the task branch, which reach whatever they reach. The -// connector does not judge that and does not delete any of it — that is the -// class of defect this stopped trying to get right — so the row is kept, -// said to be orphaned, and listed with its record and the refs in it. Only an -// operator's explicit discard (`worktrees prune --force `) deletes the -// branch, having been told what goes. -func (w *Worktrees) forget(ctx context.Context, r Worktree, from []WorktreeState, how *removal, at *string) Worktree { - if w.movedElsewhere(ctx, r) { - // Moved out from under the connector: its files are someone's. - return w.retain(ctx, r, RetainedMoved, from) - } - tip, err := w.branchTip(ctx, r) - if err != nil { - if how != nil && how.force { - // The repository cannot be read at all, so there is nothing here - // to delete and nothing to keep the row for: an operator who - // named it gets it closed rather than a row nothing can clear. - w.log.Warn("connector: a worktree's repository could not be read; the row is closed as the operator asked", "path", r.Path, "error", err) - return w.recordGone(ctx, r, from) - } - return w.retain(ctx, r, RetainedUnverified, from) - } - ours := tip != "" && r.BranchCreated && strings.HasPrefix(r.Branch, BranchPrefix) - if !ours { - // No branch of the connector's making is left. What may be left is - // git's record of the worktree, which reaches whatever it reaches: a - // row that never stored where that is asks the repository, and a row - // whose record cannot be looked for is kept, not closed. - record, err := w.findRecord(ctx, r) - switch { - case err != nil: - return w.retain(ctx, r, RetainedUnverified, from) - case record == "": - // Nothing of the connector's is left: no branch it made, no - // record. There is nothing to decide and nothing to delete. - return w.recordGone(ctx, r, from) - } - } - if how == nil || !how.force { - return w.retain(ctx, r, RetainedOrphaned, from) - } - // The operator named this worktree: the branch it made goes, at the - // commit it stands at, and git's record is left for `git worktree prune`. - // A branch that could not be deleted keeps the row, so nothing is left - // behind that nothing lists. - if ours { - if !w.deleteBranch(ctx, r, tip) { - return w.retain(ctx, r, RetainedOrphaned, from) - } - if at != nil { - *at = tip - } - } - return w.recordGone(ctx, r, from) -} - -// findRecord is git's record of this worktree — /.git/worktrees/ — -// found by the path it names, for a row that never stored where it is: "" when -// the repository has no record of this worktree. It answers where the record -// is and nothing about what it reaches. Anything unreadable is an error, never -// a "no". -func (w *Worktrees) findRecord(ctx context.Context, r Worktree) (string, error) { - if r.AdminDir != "" { - if _, err := os.Lstat(r.AdminDir); errors.Is(err, os.ErrNotExist) { - return "", nil - } else if err != nil { - return "", err - } - return r.AdminDir, nil - } - common, err := w.gitOut(ctx, r.Repository, "rev-parse", "--path-format=absolute", "--git-common-dir") - if err != nil { - return "", err - } - dir := filepath.Join(common, "worktrees") - entries, err := os.ReadDir(dir) - if errors.Is(err, os.ErrNotExist) { - return "", nil - } - if err != nil { - return "", err - } - for _, entry := range entries { - if !entry.IsDir() { - continue - } - admin := filepath.Join(dir, entry.Name()) - at, err := os.ReadFile(filepath.Join(admin, "gitdir")) - if errors.Is(err, os.ErrNotExist) { - continue - } - if err != nil { - return "", err - } - named := strings.TrimSpace(string(at)) - if !filepath.IsAbs(named) { - named = filepath.Join(admin, named) - } - if samePath(filepath.Dir(named), r.Path) { - return admin, nil - } - } - return "", nil -} - -// recordGone records a row whose worktree is not on disk and has nothing left -// to decide. -func (w *Worktrees) recordGone(ctx context.Context, r Worktree, from []WorktreeState) Worktree { - gone := RemovedMissing - if r.State == WorktreeCreating { - gone = RemovedNeverCreated - } - if err := w.ledger.RemovedWorktree(ctx, r.ID, gone, from...); err != nil { - w.log.Warn("connector: recording a worktree gone", "path", r.Path, "error", err) - return r - } - r.State, r.RemovedBy = WorktreeRemoved, gone - return r -} - -func (w *Worktrees) settleKeeping(ctx context.Context, r Worktree, by RemovedBy, force bool, refs *[]string, at *string) Worktree { - from := []WorktreeState{r.State} - // A removal a crash interrupted: its names come back first, and it is - // judged as it stands. - if restored, ok := w.unfreeze(r); !ok { - w.log.Warn("connector: a frozen worktree could not be restored; kept", "path", r.Path) - return w.retain(ctx, r, RetainedUnverified, from) - } else if restored { - w.log.Info("connector: restored a worktree a removal left frozen", "path", r.Path) - } - - if !exists(r.Path) { - return w.forget(ctx, r, from, &removal{force: force}, at) - } - return w.removeWorktree(ctx, r, by, removal{force: force}, refs) -} - -// removal is how removeWorktree judges. -type removal struct { - // force is an operator's explicit discard: unheld commits are kept under - // refs and the worktree goes. - force bool -} - -// RemovingSuffix is what a removal adds to a worktree's name and to its -// record's while it judges them: a directory under it is a removal that is -// running, or one a crash left for the next start to restore. -const RemovingSuffix = ".removing" - -// frozenName is where removeWorktree moves a name while it judges. -func frozenName(path string) string { return path + RemovingSuffix } - -// removeWorktree is the one removal (the rule, in the type's doc). It claims -// the row, freezes the worktree, judges it frozen, and deletes the frozen copy -// or restores it and retains the row. The caller holds the lock and has seen -// the directory there. -func (w *Worktrees) removeWorktree(ctx context.Context, r Worktree, by RemovedBy, how removal, refs *[]string) Worktree { - from := []WorktreeState{r.State} - admin := r.AdminDir - if admin == "" { - // A row whose record's place was never stored: found, proven to be - // this worktree's own record, and stored before anything is renamed. - found, err := w.recordOf(ctx, r) - if err != nil { - return w.retain(ctx, r, RetainedUnverified, from) - } - if err := w.ledger.WorktreeAdminDir(ctx, r.ID, found); err != nil { - return w.retain(ctx, r, RetainedUnverified, from) - } - admin, r.AdminDir = found, found - } - if r.State != WorktreeRemoving { - if err := w.ledger.MoveWorktree(ctx, r.ID, WorktreeRemoving, from...); err != nil { - w.log.Warn("connector: claiming a worktree for removal", "path", r.Path, "error", err) - return r - } - r.State = WorktreeRemoving - } - removing := []WorktreeState{WorktreeRemoving} - - // Freeze: lock the record, rename it, then the directory. The lock is - // git's own: a frozen record's gitdir names a directory that is not there, - // and git's prune deletes such a record unless it is locked. - switch taken, err := lockRecord(admin); { - case err != nil: - return w.retain(ctx, r, RetainedUnverified, removing) - case !taken: - return w.retain(ctx, r, RetainedLocked, removing) - } - v := view{dir: frozenName(r.Path), gitDir: frozenName(admin)} - if err := os.Rename(admin, v.gitDir); err != nil { - unlockRecord(admin) - return w.retain(ctx, r, RetainedUnverified, removing) - } - if err := os.Rename(r.Path, v.dir); err != nil { - if os.Rename(v.gitDir, admin) == nil { - unlockRecord(admin) - } else { - w.log.Warn("connector: a worktree's record could not be restored; the next start restores it", "path", r.Path) - return r - } - return w.retain(ctx, r, RetainedUnverified, removing) - } - if w.whileFrozen != nil { - if err := w.whileFrozen(v.dir); err != nil { - // A test standing in for a crash: names stay frozen. - return r - } - } - - judged := w.judge(ctx, r, v, how) - if judged.reason == "" && how.force && len(judged.unheld) > 0 { - // A force keeps what nothing holds before anything else happens, and - // those refs hold it from here on: the transaction below verifies - // them with every other holder. - kept, err := w.keepCommits(ctx, r, judged.unheld) - if err != nil { - judged.reason = RetainedUnverified - } else { - if refs != nil { - *refs = append(*refs, kept...) - } - for i, ref := range kept { - judged.holds = append(judged.holds, hold{ref: ref, oid: judged.unheld[i], commit: judged.unheld[i]}) - } - } - } - if judged.reason != "" { - if !w.restore(r, v, admin) { - w.log.Warn("connector: a frozen worktree could not be restored; the next start restores it", "path", r.Path) - return r - } - return w.retain(ctx, r, judged.reason, removing) - } - - // Every commit the worktree reaches is held under a ref of the - // connector's own before anything is deleted, and let go only once the - // removal is over. Whatever else holds those commits — a remote branch a - // fetch prunes, a branch someone deletes — may go while the removal runs: - // it takes nothing with it. These refs hold nothing for anybody else (see - // RemovingRefPrefix), so one left by a crash cannot pass for a holder. - if _, err := w.anchor(ctx, r, judged.tips); err != nil { - w.log.Warn("connector: a worktree's commits could not be held for its removal; kept", "path", r.Path, "error", err) - if w.restore(r, v, admin) { - return w.retain(ctx, r, RetainedUnverified, removing) - } - return r - } - // The branch goes first, in the transaction that proves the judgment - // still stands: every ref the judgment leaned on is verified where it was - // found, so a fetch, a reset or a branch deleted since makes git refuse - // the whole thing and the worktree is kept instead. It is deleted only at - // a commit judged held, so a crash between the two leaves nothing - // unreachable, while the other order would leave a branch nothing later - // settles. - if !w.endBranch(ctx, r, judged) { - // The removal does not happen: its anchors go, each only while what - // the judgment found still holds its commit. - w.dropAnchors(ctx, r, judged) - if w.restore(r, v, admin) { - return w.retain(ctx, r, RetainedUnverified, removing) - } - w.log.Warn("connector: a frozen worktree could not be restored; the next start restores it", "path", r.Path) - return r - } - // Delete the frozen copy: the directory, then the record. - if err := os.RemoveAll(v.dir); err != nil { - w.log.Warn("connector: a frozen worktree could not be deleted; kept", "path", r.Path, "error", err) - // The branch went first: a worktree that comes back comes back whole, - // checked out on the branch it was checked out on. - w.putBranchBack(ctx, r, judged.tip) - w.dropAnchors(ctx, r, judged) - if w.restore(r, v, admin) { - return w.retain(ctx, r, RetainedUnverified, removing) - } - return r - } - if err := os.RemoveAll(v.gitDir); err != nil { - // The directory is gone but git's record of it is not, and it is - // still frozen and locked. The row stays removing, which is a row the - // next start restores and judges again, rather than a removed row - // nothing lists and nothing reconciles. - w.log.Warn("connector: a worktree's record could not be deleted; the next start restores it", "path", r.Path, "error", err) - return r - } - // The worktree is gone: the anchors of its held commits are let go, each - // only while the ref the judgment found still holds its commit. One that - // moved keeps its anchor, and the operator is told which. - if left := w.dropAnchors(ctx, r, judged); len(left) > 0 && refs != nil { - *refs = append(*refs, left...) - } - if err := w.ledger.RemovedWorktree(ctx, r.ID, by, removing...); err != nil { - // The worktree is gone; the row still says removing, and the next - // settle records it missing. Nobody is told it was kept. - w.log.Warn("connector: a worktree was removed but the ledger could not record it", "path", r.Path, "error", err) - return r - } - r.State, r.RemovedBy = WorktreeRemoved, by - return r -} - -// restore gives a frozen worktree its names back: the directory, then the -// record. -func (w *Worktrees) restore(r Worktree, v view, admin string) bool { - if exists(v.dir) && os.Rename(v.dir, r.Path) != nil { - return false - } - if exists(v.gitDir) && os.Rename(v.gitDir, admin) != nil { - return false - } - unlockRecord(admin) - return true -} - -// recordLockReason marks a lock on git's record of a worktree as the -// connector's own, taken while a removal holds it frozen. -const recordLockReason = "basecamp-connect: removal in progress\n" - -// lockRecord locks git's record of a worktree for the connector, the way -// `git worktree lock` does, if nobody holds a lock on it. taken is false for a -// lock someone else holds. -func lockRecord(admin string) (taken bool, err error) { - f, err := os.OpenFile(filepath.Join(admin, "locked"), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) - if errors.Is(err, os.ErrExist) { - return ownRecordLock(filepath.Join(admin, "locked")), nil - } - if err != nil { - return false, err - } - if _, err := f.WriteString(recordLockReason); err != nil { - _ = f.Close() - _ = os.Remove(f.Name()) - return false, err - } - return true, f.Close() -} - -// ownRecordLock reports whether a record's lock file is the connector's. -func ownRecordLock(path string) bool { - content, err := os.ReadFile(path) - return err == nil && string(content) == recordLockReason -} - -// unlockRecord removes the connector's own lock on a record, never another's. -func unlockRecord(admin string) { - path := filepath.Join(admin, "locked") - if ownRecordLock(path) { - _ = os.Remove(path) - } -} - -// recordOf finds git's record of a worktree and proves it is this worktree's: -// under the repository's common directory's worktrees/, with a gitdir that -// names this worktree's .git. -func (w *Worktrees) recordOf(ctx context.Context, r Worktree) (string, error) { - admin, err := w.gitOut(ctx, r.Path, "rev-parse", "--absolute-git-dir") - if err != nil { - return "", err - } - common, err := w.gitOut(ctx, r.Repository, "rev-parse", "--path-format=absolute", "--git-common-dir") - if err != nil { - return "", err - } - if !samePath(filepath.Dir(admin), filepath.Join(common, "worktrees")) { - return "", fmt.Errorf("connector: %s is not a worktree record of %s", admin, r.Repository) - } - at, err := os.ReadFile(filepath.Join(admin, "gitdir")) - if err != nil { - return "", err - } - named := strings.TrimSpace(string(at)) - if !filepath.IsAbs(named) { - named = filepath.Join(admin, named) - } - if !samePath(filepath.Dir(named), r.Path) { - return "", fmt.Errorf("connector: %s is another worktree's record", admin) - } - return admin, nil -} - -// unfreeze restores the names of a worktree a crash left frozen. It reports -// whether it restored anything, and false in ok when a frozen name is there -// but cannot be put back. -func (w *Worktrees) unfreeze(r Worktree) (restored, ok bool) { - pairs := [][2]string{{frozenName(r.Path), r.Path}} - if r.AdminDir != "" { - pairs = append(pairs, [2]string{frozenName(r.AdminDir), r.AdminDir}) - } - for _, p := range pairs { - if !exists(p[0]) { - continue - } - if exists(p[1]) || os.Rename(p[0], p[1]) != nil { - return restored, false - } - restored = true - } - if r.AdminDir != "" { - unlockRecord(r.AdminDir) - } - return restored, true -} - -// view is how git is pointed at a worktree: its directory, and, for a frozen -// one, git's record of it by its frozen name. -type view struct { - dir string - gitDir string -} - -func (v view) args(args ...string) []string { - if v.gitDir == "" { - return append([]string{"-C", v.dir}, args...) - } - return append([]string{"-C", v.dir, "--git-dir", v.gitDir, "--work-tree", v.dir}, args...) -} - -// hold is a ref the connector keeps, the commit it pointed at when a judgment -// leaned on it, and the commit of the worktree it was found to hold. -type hold struct{ ref, oid, commit string } - -// judgment is what judge decided about a frozen worktree: the reason to keep -// it, or "" with the task branch's tip ("" when the branch is gone), the refs -// that hold every commit the removal would forget, and, for a force, the -// commits nothing holds. -type judgment struct { - reason RetainedReason - tip string - // tips is every commit the worktree reaches that removing it would - // forget; unheld are the ones nothing else holds, which only a force - // reaches; holds are the refs that hold the rest. - tips []string - unheld []string - holds []hold -} - -// pseudoRefs are the record's own refs outside refs/: what a reset, a fetch or -// an operation in progress left in /.git/worktrees/, and what goes -// with the record when it is deleted. -var pseudoRefs = []string{"ORIG_HEAD", "FETCH_HEAD", "MERGE_HEAD", "REBASE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "AUTO_MERGE", "BISECT_EXPECTED_REV", "MERGE_AUTOSTASH"} - -// autostashFiles are where a rebase keeps the commit it stashed away: not a -// ref, a file in the record naming one, and nothing else reaches it. -var autostashFiles = []string{filepath.Join("rebase-merge", "autostash"), filepath.Join("rebase-apply", "autostash")} - -// judge decides whether a frozen worktree holds anything that could be lost. -func (w *Worktrees) judge(ctx context.Context, r Worktree, v view, how removal) judgment { - gitPath := func(name string) string { return filepath.Join(v.gitDir, name) } - switch _, err := os.Lstat(gitPath("locked")); { - case err == nil && !ownRecordLock(gitPath("locked")): - return judgment{reason: RetainedLocked} - case err == nil: - case !errors.Is(err, os.ErrNotExist): - return judgment{reason: RetainedUnverified} - } - // A submodule's git data is never lost, and never forced away: no ref - // here can keep it. - switch entries, err := os.ReadDir(gitPath("modules")); { - case err == nil && len(entries) > 0: - return judgment{reason: RetainedDirty} - case err != nil && !errors.Is(err, os.ErrNotExist): - return judgment{reason: RetainedUnverified} - } - if !how.force { - for _, marker := range []string{"MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "BISECT_LOG", "rebase-merge", "rebase-apply", "sequencer"} { - switch _, err := os.Lstat(gitPath(marker)); { - case err == nil: - return judgment{reason: RetainedDirty} - case !errors.Is(err, os.ErrNotExist): - return judgment{reason: RetainedUnverified} - } - } - } - // The disk before any git command that could recurse: whatever is not a - // tracked file is work, and a submodule directory holding anything — a git - // directory and configuration a worker planted among it — is work git is - // never asked to look inside. - untracked, gitlinkContent, err := w.untrackedOnDisk(ctx, v) - switch { - case err != nil: - return judgment{reason: RetainedUnverified} - case gitlinkContent: - return judgment{reason: RetainedDirty} - case untracked && !how.force: - return judgment{reason: RetainedDirty} - } - // A checkout that never happened — a crash between `worktree add - // --no-checkout` and the checkout — holds git's .git file and nothing - // else, with an empty index. There is nothing in it to lose, though the - // rule below would read that index against HEAD as every file deleted. - bare, err := w.neverCheckedOut(ctx, v) - if err != nil { - return judgment{reason: RetainedUnverified} - } - if !how.force && !bare { - status, err := w.gitRawIn(ctx, v, "status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignored=traditional", "--ignore-submodules=all") - if err != nil { - return judgment{reason: RetainedUnverified} - } - if len(status) > 0 { - return judgment{reason: RetainedDirty} - } - // An index entry marked skip-worktree or assume-unchanged hides its - // edits from status. - entries, err := w.gitRawIn(ctx, v, "ls-files", "-v", "-z") - if err != nil { - return judgment{reason: RetainedUnverified} - } - for entry := range strings.SplitSeq(string(entries), "\x00") { - if entry == "" { - continue - } - if tag := entry[0]; tag == 'S' || (tag >= 'a' && tag <= 'z') { - return judgment{reason: RetainedDirty} - } - } - } - - tip, err := w.branchTip(ctx, r) - if err != nil { - return judgment{reason: RetainedUnverified} - } - // Every commit the worktree or its branch reaches, and that removing it - // would forget: HEAD, the branch, their reflogs, per-worktree refs. - var tips []string - // A HEAD that names no commit — a worker's `checkout --orphan`, or an - // unborn branch — reaches nothing through HEAD, and that is an answer, - // not doubt: what HEAD stood at before is still read from its reflog - // below. `--quiet` says it with exit 1 and nothing else, so a git the - // connector could not run still leaves the worktree unjudged. - head, err := w.gitRawIn(ctx, v, "rev-parse", "--quiet", "--verify", "--end-of-options", "HEAD^{commit}") - unborn := false - switch { - case err == nil: - tips = append(tips, strings.TrimSpace(string(head))) - case noSuchRevision(err): - unborn = true - default: - return judgment{reason: RetainedUnverified} - } - if tip != "" { - tips = append(tips, tip) - out, err := w.gitOut(ctx, r.Repository, "reflog", "show", "--format=%H", "refs/heads/"+r.Branch, "--") - if err != nil { - return judgment{reason: RetainedUnverified} - } - tips = append(tips, strings.Fields(out)...) - } - // HEAD's reflog holds every commit it stood at, and an unborn HEAD is - // the one HEAD `reflog show` will not name — while the file still holds - // what it stood at before, which nothing else reaches. So it is read as - // the per-worktree reflogs below are, and an orphan loses no history. - if unborn { - logged, err := reflogFileTips(filepath.Join(v.gitDir, "logs", "HEAD")) - if err != nil { - return judgment{reason: RetainedUnverified} - } - tips = append(tips, logged...) - } else { - out, err := w.gitRawIn(ctx, v, "reflog", "show", "--format=%H", "HEAD", "--") - if err != nil { - return judgment{reason: RetainedUnverified} - } - tips = append(tips, strings.Fields(string(out))...) - } - perWorktree, err := w.gitRawIn(ctx, v, "for-each-ref", "--format=%(objectname)", "refs/worktree/", "refs/bisect/", "refs/rewritten/") - if err != nil { - return judgment{reason: RetainedUnverified} - } - tips = append(tips, strings.Fields(string(perWorktree))...) - // Those refs' own reflogs, when the repository keeps them: git logs ref - // updates under refs/ only with core.logAllRefUpdates=always, and a - // per-worktree ref's log lives in the record and goes with it. - for _, dir := range []string{"refs/worktree", "refs/bisect", "refs/rewritten"} { - logged, err := reflogDirTips(filepath.Join(v.gitDir, "logs", filepath.FromSlash(dir))) - if err != nil { - return judgment{reason: RetainedUnverified} - } - tips = append(tips, logged...) - } - // - // The record's pseudo-refs are its too, and go with it: ORIG_HEAD is what - // a reset left behind, and the rest are an operation's. They are read as - // the files they are, because some of them — FETCH_HEAD, and MERGE_HEAD - // in an octopus merge — name more than one commit, which `rev-parse` - // would reduce to the first. - named, err := pseudoRefTips(v.gitDir) - if err != nil { - return judgment{reason: RetainedUnverified} - } - tips = append(tips, named...) - stashed, err := autostashTips(v.gitDir) - if err != nil { - return judgment{reason: RetainedUnverified} - } - tips = append(tips, stashed...) - // A reflog that is not there is not a reflog that holds nothing: with - // core.logAllRefUpdates off, or after an expire, what the worktree - // reached is unreadable, and what cannot be read is not judged clean — - // unless an operator names this worktree and forces it, which is a - // decision about work, not a judgment. Every repository keeps reflogs by - // default; one that does not would otherwise leave rows nothing could - // ever clear. - if !bare && !how.force { - if _, err := os.Lstat(filepath.Join(v.gitDir, "logs", "HEAD")); err != nil { - return judgment{reason: RetainedUnverified} - } - } - slices.Sort(tips) - // Only commits: a pseudo-ref or a ref pointed at a tree or a blob names - // no history, and asking what contains one is a question git answers - // with an error — which would keep the worktree for ever. A conflicted - // merge leaves exactly that in AUTO_MERGE. - commits, err := w.commitsAmong(ctx, v, slices.Compact(tips)) - if err != nil { - return judgment{reason: RetainedUnverified} - } - decided := judgment{tip: tip, tips: commits} - for _, commit := range decided.tips { - // The ref that holds it, and where that ref stands: the removal - // verifies each one again, in the transaction that ends the branch, - // so a holder that moved in between stops the removal. - ref, oid, err := w.holder(ctx, r, commit) - switch { - case err != nil: - return judgment{reason: RetainedUnverified} - case ref == "": - if !how.force { - return judgment{reason: RetainedUnpushed} - } - decided.unheld = append(decided.unheld, commit) - default: - decided.holds = append(decided.holds, hold{ref: ref, oid: oid, commit: commit}) - } - } - return decided -} - -// dropAnchors lets go of the refs a removal held its commits under, each only -// while the ref the judgment found still holds that commit. It returns the -// anchors that stay, because what held their commits moved. -func (w *Worktrees) dropAnchors(ctx context.Context, r Worktree, judged judgment) []string { - var left []string - for _, h := range judged.holds { - anchor := anchorRef(r, h.commit) - stdin := "start\nverify " + h.ref + " " + h.oid + "\ndelete " + anchor + " " + h.commit + "\nprepare\ncommit\n" - if err := w.gitStdin(ctx, r.Repository, stdin, "update-ref", "--stdin"); err != nil { - w.log.Info("connector: a commit of a removed worktree is kept under a ref: what held it moved", "ref", anchor, "path", r.Path) - left = append(left, anchor) - } - } - return left -} - -// retainedRef is where a commit of this worktree is kept for the operator. -func retainedRef(r Worktree, commit string) string { - return RetainedRefPrefix + safeName(filepath.Base(r.Path)) + "/" + commit -} - -// anchorRef is where a removal holds a commit of this worktree while it runs. -func anchorRef(r Worktree, commit string) string { - return RemovingRefPrefix + safeName(filepath.Base(r.Path)) + "/" + commit -} - -// keepCommits keeps each commit under refs/basecamp-connect/retained// -// , create-only; a ref already there at that commit is the same keep. -func (w *Worktrees) keepCommits(ctx context.Context, r Worktree, commits []string) ([]string, error) { - return w.holdUnder(ctx, r, commits, retainedRef) -} - -// anchor holds each commit under RemovingRefPrefix for as long as a removal -// runs. -func (w *Worktrees) anchor(ctx context.Context, r Worktree, commits []string) ([]string, error) { - return w.holdUnder(ctx, r, commits, anchorRef) -} - -func (w *Worktrees) holdUnder(ctx context.Context, r Worktree, commits []string, where func(Worktree, string) string) ([]string, error) { - refs := make([]string, 0, len(commits)) - var made []string - for _, commit := range commits { - ref := where(r, commit) - if _, err := w.gitOut(ctx, r.Repository, "update-ref", "--end-of-options", ref, commit, ""); err != nil { - at, atErr := w.gitOut(ctx, r.Repository, "rev-parse", "--verify", "--end-of-options", ref) - if atErr != nil || at != commit { - // Holding them all failed, so none is held: the ones this - // call made are let go again, and a ref that was already - // there — an earlier force's keep — is left alone. - for _, ref := range made { - if _, err := w.gitOut(ctx, r.Repository, "update-ref", "-d", "--end-of-options", ref); err != nil { - w.log.Warn("connector: a ref made to hold a commit could not be let go", "ref", ref, "error", err) - } - } - return nil, err - } - } else { - made = append(made, ref) - } - refs = append(refs, ref) - } - return refs, nil -} - -// movedElsewhere reports whether the repository still has a worktree on this -// row's branch somewhere else: someone moved it, and its files are work the -// connector neither judges nor forgets, so the row is kept. -func (w *Worktrees) movedElsewhere(ctx context.Context, r Worktree) bool { - // The repository's record of this worktree names where it is now, - // whatever it has checked out and whatever its branch is called. - if r.AdminDir != "" { - switch at, err := os.ReadFile(filepath.Join(r.AdminDir, "gitdir")); { - case err == nil: - // The record is the worktree's .git file, absolute or — with - // worktree.useRelativePaths — relative to the admin directory. - path := strings.TrimSpace(string(at)) - if !filepath.IsAbs(path) { - path = filepath.Join(r.AdminDir, path) - } - path = filepath.Dir(path) - return !samePath(path, r.Path) && exists(path) - case !errors.Is(err, os.ErrNotExist): - // The record cannot be read: assume it is still somewhere. - return true - } - return false - } - // A row from before the admin directory was recorded: its branch is the - // only handle left. - out, err := w.gitRaw(ctx, r.Repository, "worktree", "list", "--porcelain", "-z") - if err != nil { - return true - } - var current string - for field := range strings.SplitSeq(string(out), "\x00") { - switch { - case strings.HasPrefix(field, "worktree "): - current = strings.TrimPrefix(field, "worktree ") - case field == "branch refs/heads/"+r.Branch: - if !samePath(current, r.Path) && exists(current) { - return true - } - } - } - return false -} - -// reflogDirTips is every commit the reflogs under one directory of a record -// name. A directory that is not there is a repository that logs nothing -// there, which names nothing. -func reflogDirTips(dir string) ([]string, error) { - var tips []string - err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { - switch { - case errors.Is(err, os.ErrNotExist): - return nil - case err != nil: - return err - case d.IsDir(): - return nil - } - logged, err := reflogFileTips(path) - if err != nil { - return err - } - tips = append(tips, logged...) - return nil - }) - if err != nil { - return nil, err - } - return tips, nil -} - -// reflogFileTips is every commit a reflog file names, read as git writes it: -// one line per entry, the commit before it and the commit after it first. A -// reflog that is not there names nothing; one that cannot be read is an error, -// never an empty answer. -func reflogFileTips(path string) ([]string, error) { - data, err := os.ReadFile(path) - if errors.Is(err, os.ErrNotExist) { - return nil, nil - } - if err != nil { - return nil, err - } - var tips []string - for line := range strings.SplitSeq(string(data), "\n") { - fields := strings.Fields(line) - // The two object names an entry starts with; the rest of the line is - // who, when and why, which name nothing. - for _, field := range fields[:min(2, len(fields))] { - if !isObjectName(field) { - continue - } - tips = append(tips, field) - } - } - return tips, nil -} - -// commitsAmong is the commits these object names reach: the name itself, or -// what an annotated tag points at, because that is the history the name -// keeps. A tree or a blob reaches no commit and neither does an object that -// is no longer there, and both are dropped — there is nothing in them to -// lose. Git answers once per name, in order; answering for fewer is an error, -// never a quiet drop. -func (w *Worktrees) commitsAmong(ctx context.Context, v view, oids []string) ([]string, error) { - if len(oids) == 0 { - return nil, nil - } - var asked strings.Builder - for _, oid := range oids { - asked.WriteString(oid + "^{commit}\n") - } - out, err := w.gitRawInStdin(ctx, v, asked.String(), "cat-file", "--batch-check=%(objectname) %(objecttype)") - if err != nil { - return nil, err - } - answers := strings.Split(strings.TrimSuffix(string(out), "\n"), "\n") - if len(answers) != len(oids) { - return nil, fmt.Errorf("connector: git answered for %d of %d object names", len(answers), len(oids)) - } - commits := make([]string, 0, len(oids)) - for _, answer := range answers { - if name, kind, ok := strings.Cut(answer, " "); ok && kind == "commit" { - commits = append(commits, name) - } - } - slices.Sort(commits) - return slices.Compact(commits), nil -} - -// pseudoRefTips is every object name the record's pseudo-refs hold: one per -// line, first field, as git writes FETCH_HEAD and the rest. A file that is -// not there names nothing; one that cannot be read is an error. -func pseudoRefTips(gitDir string) ([]string, error) { - var tips []string - for _, name := range pseudoRefs { - data, err := os.ReadFile(filepath.Join(gitDir, name)) - switch { - case errors.Is(err, os.ErrNotExist): - continue - case err != nil: - return nil, err - } - for line := range strings.SplitSeq(string(data), "\n") { - fields := strings.Fields(line) - if len(fields) > 0 && isObjectName(fields[0]) { - tips = append(tips, fields[0]) - } - } - } - return tips, nil -} - -// autostashTips is every commit an operation in progress stashed away in a -// record: git writes the object name to a file, and nothing else names it. -func autostashTips(gitDir string) ([]string, error) { - var tips []string - for _, name := range autostashFiles { - data, err := os.ReadFile(filepath.Join(gitDir, name)) - switch { - case errors.Is(err, os.ErrNotExist): - continue - case err != nil: - return nil, err - } - if oid := strings.TrimSpace(string(data)); isObjectName(oid) { - tips = append(tips, oid) - } - } - return tips, nil -} - -// isGitDir reports whether a directory is a repository's git data: git's own -// test is a HEAD, an objects directory and a refs directory. -func isGitDir(path string) bool { - for _, name := range []string{"HEAD", "objects", "refs"} { - if _, err := os.Lstat(filepath.Join(path, name)); err != nil { - return false - } - } - return true -} - -// isObjectName reports whether a field is an object name and not the zero one. -func isObjectName(field string) bool { - return len(field) >= 40 && strings.Trim(field, "0123456789abcdef") == "" && strings.Trim(field, "0") != "" -} - -// noSuchRevision reports whether git said a revision does not resolve, which -// `rev-parse --quiet` says with exit 1 and nothing else. Any other failure is -// a git that could not be run, which is never an answer about work. -func noSuchRevision(err error) bool { - var exitErr *exec.ExitError - return errors.As(err, &exitErr) && exitErr.ExitCode() == 1 -} - -// exists reports whether a path is anything but proven absent: a path that -// cannot be read counts as there, because an error is not evidence that work -// is gone. -func exists(path string) bool { - _, err := os.Lstat(path) - return !errors.Is(err, os.ErrNotExist) -} - -func (w *Worktrees) retain(ctx context.Context, r Worktree, reason RetainedReason, from []WorktreeState) Worktree { - if err := w.ledger.RetainWorktree(ctx, r.ID, reason, from...); err != nil { - w.log.Warn("connector: recording a worktree retained", "path", r.Path, "error", err) - return r - } - w.log.Info("connector: worktree retained", "path", r.Path, "branch", r.Branch, "reason", string(reason)) - r.State, r.RetainedReason = WorktreeRetained, reason - return r -} - -// neverCheckedOut reports whether a worktree's directory holds nothing but -// git's .git file and its index is empty: `git worktree add --no-checkout` -// ran and the checkout that follows it did not. -func (w *Worktrees) neverCheckedOut(ctx context.Context, v view) (bool, error) { - entries, err := os.ReadDir(v.dir) - if err != nil { - return false, err - } - if len(entries) != 1 || entries[0].Name() != ".git" || entries[0].IsDir() { - return false, nil - } - index, err := w.gitRawIn(ctx, v, "ls-files", "--stage", "-z") - if err != nil { - return false, err - } - return len(strings.TrimSpace(string(index))) == 0, nil -} - -// untrackedOnDisk reports whether a worktree's directory holds anything that -// is not a file git tracks (an untracked or ignored file, a directory git has -// no file in), and separately whether a submodule's directory, which the -// checkout left empty, holds anything at all. Symlinks are not followed. -func (w *Worktrees) untrackedOnDisk(ctx context.Context, v view) (untracked, gitlinkContent bool, err error) { - out, err := w.gitRawIn(ctx, v, "ls-files", "--stage", "-z") - if err != nil { - return false, false, err - } - files, gitlinks, dirs := map[string]bool{}, map[string]bool{}, map[string]bool{".": true} - for entry := range strings.SplitSeq(string(out), "\x00") { - meta, path, ok := strings.Cut(entry, "\t") - if !ok { - continue - } - if strings.HasPrefix(meta, "160000 ") { - gitlinks[path] = true - } else { - files[path] = true - } - for dir := filepath.Dir(filepath.FromSlash(path)); dir != "."; dir = filepath.Dir(dir) { - dirs[filepath.ToSlash(dir)] = true - } - } - // Bounded: a tree too big to read in time, or a filesystem call that never - // returns (a mount a worker left), is not proven clean, and must not hold - // the connector's shutdown. The walk runs apart and is abandoned at the - // deadline; a call stuck in the kernel keeps only its own goroutine. - deadline := time.Now().Add(w.walkLimit) - type verdict struct { - untracked, gitlink bool - err error - } - walked := make(chan verdict, 1) - go func() { - var found verdict - found.err = filepath.WalkDir(v.dir, func(path string, d os.DirEntry, err error) error { - if err != nil { - return err - } - if ctx.Err() != nil { - return ctx.Err() - } - if time.Now().After(deadline) { - return errors.New("connector: the worktree could not be read in time") - } - rel, err := filepath.Rel(v.dir, path) - if err != nil { - return err - } - rel = filepath.ToSlash(rel) - switch { - case rel == ".git" && !d.IsDir(): - // The worktree's link to its repository. - return nil - case filepath.Base(rel) == ".git": - // Git data of a repository inside the worktree — a submodule - // git someone initialized, a repository a worker made, or a - // .git the worktree's own was replaced with. No ref here can - // keep its commits, so it is never removed, forced or not. - found.gitlink = true - return filepath.SkipAll - case gitlinks[rel]: - if !d.IsDir() { - found.gitlink = true - return filepath.SkipAll - } - entries, err := os.ReadDir(path) - if err != nil { - return err - } - if len(entries) > 0 { - found.gitlink = true - return filepath.SkipAll - } - return filepath.SkipDir - case d.IsDir(): - // A directory that is itself a repository — `git init --bare`, - // or a clone with no worktree — is git data like any other, - // wherever it is: no ref here can keep its commits, so it is - // never removed, forced or not. The worktree's own directory - // is one of these to look at: a worker can make a repository - // of the root it works in. - if isGitDir(path) { - found.gitlink = true - return filepath.SkipAll - } - if !dirs[rel] { - found.untracked = true - } - return nil - case !files[rel]: - found.untracked = true - } - return nil - }) - walked <- found - }() - timer := time.NewTimer(time.Until(deadline)) - defer timer.Stop() - select { - case found := <-walked: - return found.untracked, found.gitlink, found.err - case <-timer.C: - return false, false, errors.New("connector: the worktree could not be read in time") - case <-ctx.Done(): - return false, false, ctx.Err() - } -} - -// holder is a ref the connector keeps that contains commit, and the commit it -// points at; "" when there is none. -func (w *Worktrees) holder(ctx context.Context, r Worktree, commit string) (string, string, error) { - own := RetainedRefPrefix + safeName(filepath.Base(r.Path)) + "/" - out, err := w.gitOut(ctx, r.Repository, "for-each-ref", "--format=%(refname) %(objectname)", "--contains", commit, "refs/remotes", "refs/heads", own) - if err != nil { - return "", "", err - } - for line := range strings.SplitSeq(out, "\n") { - ref, oid, ok := strings.Cut(line, " ") - switch { - case !ok, strings.HasPrefix(ref, "refs/heads/"+BranchPrefix): - case strings.HasPrefix(ref, "refs/remotes/"), strings.HasPrefix(ref, "refs/heads/"), strings.HasPrefix(ref, own): - return ref, oid, nil - } - } - return "", "", nil -} - -func (w *Worktrees) branchTip(ctx context.Context, r Worktree) (string, error) { - out, err := w.gitRaw(ctx, r.Repository, "for-each-ref", "--format=%(objectname)", "refs/heads/"+r.Branch) - if err != nil { - return "", err - } - return strings.TrimSpace(string(out)), nil -} - -// putBranchBack makes the task branch again, at the commit it was deleted at, -// for a removal that could not go through: what came back must be what was -// there. -func (w *Worktrees) putBranchBack(ctx context.Context, r Worktree, commit string) { - if commit == "" || !r.BranchCreated || !strings.HasPrefix(r.Branch, BranchPrefix) { - return - } - if _, err := w.gitOut(ctx, r.Repository, "update-ref", "--end-of-options", "refs/heads/"+r.Branch, commit, ""); err != nil { - w.log.Warn("connector: a task branch could not be made again for a worktree that stayed", "branch", r.Branch, "error", err) - } -} - -// deleteBranch deletes the task branch of a worktree whose directory is gone, -// at the commit it stands at and only when this row made it. An operator -// asked for it by naming the worktree, and was told what goes; nothing here -// judges what the branch reaches. -func (w *Worktrees) deleteBranch(ctx context.Context, r Worktree, commit string) bool { - if commit == "" || !r.BranchCreated || !strings.HasPrefix(r.Branch, BranchPrefix) { - return true - } - stdin := "start\ndelete refs/heads/" + r.Branch + " " + commit + "\nprepare\ncommit\n" - if err := w.gitStdin(ctx, r.Repository, stdin, "update-ref", "--stdin"); err != nil { - w.log.Warn("connector: a task branch could not be deleted", "branch", r.Branch, "error", err) - return false - } - return true -} - -// endBranch is the last thing a removal does before the frozen copy goes: one -// ref transaction that verifies every ref the judgment leaned on is still -// where it was found and deletes the task branch at the tip judged held. Git -// refuses the whole transaction if any of them moved, and the removal stops. -// What it proves is that the judgment still stands when the deleting starts; -// what keeps standing while the deleting runs is the anchors. -// It reports whether the judgment still stands. -func (w *Worktrees) endBranch(ctx context.Context, r Worktree, judged judgment) bool { - stdin := "start\n" - seen := map[string]string{} - for _, h := range judged.holds { - // One verify line per ref: git refuses a transaction that names a ref - // twice. A ref found at two different commits while the judgment ran - // is a ref that moved, and one of the two cannot be verified, so the - // worktree is kept. - if at, ok := seen[h.ref]; ok { - if at != h.oid { - w.log.Warn("connector: a worktree is kept: what held its commits moved while it was judged", "path", r.Path, "ref", h.ref) - return false - } - continue - } - seen[h.ref] = h.oid - stdin += "verify " + h.ref + " " + h.oid + "\n" - } - deleting := judged.tip != "" && r.BranchCreated && strings.HasPrefix(r.Branch, BranchPrefix) - if deleting { - stdin += "delete refs/heads/" + r.Branch + " " + judged.tip + "\n" - } - if !deleting && len(seen) == 0 { - // Nothing held anything and no branch of this row's making: the - // judgment leans on nothing that could have moved. - return true - } - stdin += "prepare\ncommit\n" - if err := w.gitStdin(ctx, r.Repository, stdin, "update-ref", "--stdin"); err != nil { - w.log.Warn("connector: a worktree is kept: what held its commits moved while it was judged", "path", r.Path, "branch", r.Branch, "error", err) - return false - } - return true -} - -// WalkLimit bounds how long reading a worktree's files may take before it is -// kept as unverified. -const WalkLimit = 2 * time.Minute - -// LockWait bounds how long a settling worktree waits for another remover's -// lock. Longer than a removal takes, short enough that a stuck prune cannot -// hold a task's end, and so the connector's shutdown, open: the row is -// reconciled on the next start instead. -const LockWait = 2 * time.Minute - -// lock takes the worktrees lock (invariant 4), waiting up to LockWait for -// another holder. -func (w *Worktrees) lock(ctx context.Context) (func(), error) { - ctx, cancel := context.WithTimeout(ctx, LockWait) - defer cancel() - if err := os.MkdirAll(w.root, 0o700); err != nil { - return nil, err - } - if err := setup.EnsurePrivateDir(w.root); err != nil { - return nil, err - } - path := filepath.Join(w.root, ".lock") - for { - unlock, err := setup.TryLockPrivate(path) - switch { - case err == nil: - return func() { _ = unlock() }, nil - case !errors.Is(err, setup.ErrLockHeld): - return nil, fmt.Errorf("connector: worktrees lock: %w", err) - } - select { - case <-ctx.Done(): - return nil, ctx.Err() - case <-time.After(100 * time.Millisecond): - } - } -} - -func (w *Worktrees) gitOut(ctx context.Context, dir string, args ...string) (string, error) { - out, err := w.gitRaw(ctx, dir, args...) - return strings.TrimSpace(string(out)), err -} - -// gitRaw runs git in dir with hooks, the fsmonitor and every configured -// content filter disabled, and a fixed environment (invariant 3). -func (w *Worktrees) gitRaw(ctx context.Context, dir string, args ...string) ([]byte, error) { - return w.gitRawIn(ctx, view{dir: dir}, args...) -} - -// gitRawIn is gitRaw for a view: a frozen worktree is reached through its -// record by its frozen name. -func (w *Worktrees) gitRawIn(ctx context.Context, v view, args ...string) ([]byte, error) { - ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) - defer cancel() - guard, err := w.filterOverrides(ctx, v) - if err != nil { - return nil, err - } - return w.run(ctx, guard, v.args(args...), args[0]) -} - -// safeGit is the configuration every git call runs with. -var safeGit = [][2]string{ - {"core.hooksPath", "/dev/null"}, - {"core.fsmonitor", "false"}, - // A reflog or log that verifies signatures runs gpg.program. - {"log.showSignature", "false"}, -} - -// filterOverrides blanks every content filter git's configuration defines -// for dir. A checkout runs a path's smudge, clean or process filter, which is -// a command from configuration a worker in the checkout could have edited; -// an empty command is no filter. Reading the configuration runs nothing. -// -// The overrides travel as GIT_CONFIG_KEY_n/GIT_CONFIG_VALUE_n, not `-c`, -// which splits at the first "=" and would miss a driver whose name has one. -func (w *Worktrees) filterOverrides(ctx context.Context, v view) ([][2]string, error) { - out, err := w.run(ctx, safeGit, v.args("config", "--name-only", "--get-regexp", `^filter\.`), "config") - var exitErr *exec.ExitError - if err != nil && (!errors.As(err, &exitErr) || exitErr.ExitCode() != 1) { - // Exit 1 is "no such keys"; anything else leaves filters unknown. - return nil, err - } - guard := slices.Clone(safeGit) - seen := map[string]bool{} - for key := range strings.SplitSeq(string(out), "\n") { - rest, ok := strings.CutPrefix(strings.TrimSpace(key), "filter.") - if !ok { - continue - } - i := strings.LastIndexByte(rest, '.') - if i <= 0 || seen[rest[:i]] { - continue - } - name := rest[:i] - seen[name] = true - for _, cmd := range []string{"clean", "smudge", "process"} { - guard = append(guard, [2]string{"filter." + name + "." + cmd, ""}) - } - // A blanked filter that is also required makes git die mid-checkout - // (git lfs install sets required for its own). - guard = append(guard, [2]string{"filter." + name + ".required", "false"}) - } - return guard, nil -} - -// gitStdin runs git in dir, guarded as gitRaw is, with input on stdin. -func (w *Worktrees) gitStdin(ctx context.Context, dir, input string, args ...string) error { - ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) - defer cancel() - guard, err := w.filterOverrides(ctx, view{dir: dir}) - if err != nil { - return err - } - _, err = w.runInput(ctx, guard, view{dir: dir}.args(args...), args[0], input) - return err -} - -// gitRawInStdin is gitStdin for a view, and gives back what git wrote: a -// frozen worktree is reached through its record by its frozen name. -func (w *Worktrees) gitRawInStdin(ctx context.Context, v view, input string, args ...string) ([]byte, error) { - ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) - defer cancel() - guard, err := w.filterOverrides(ctx, v) - if err != nil { - return nil, err - } - return w.runInput(ctx, guard, v.args(args...), args[0], input) -} - -func (w *Worktrees) run(ctx context.Context, config [][2]string, args []string, what string) ([]byte, error) { - return w.runInput(ctx, config, args, what, "") -} - -func (w *Worktrees) runInput(ctx context.Context, config [][2]string, args []string, what, input string) ([]byte, error) { - cmd := exec.CommandContext(ctx, w.git, args...) //nolint:gosec // G204: git with the connector's own arguments - if input != "" { - cmd.Stdin = strings.NewReader(input) - } - env := slices.Clone(w.env) - env = append(env, "GIT_CONFIG_COUNT="+strconv.Itoa(len(config))) - for i, kv := range config { - env = append(env, "GIT_CONFIG_KEY_"+strconv.Itoa(i)+"="+kv[0], "GIT_CONFIG_VALUE_"+strconv.Itoa(i)+"="+kv[1]) - } - cmd.Env = env - var stdout, stderr bytes.Buffer - cmd.Stdout, cmd.Stderr = &stdout, &stderr - if err := cmd.Run(); err != nil { - msg := strings.TrimSpace(stderr.String()) - if len(msg) > 200 { - msg = msg[:200] - } - return stdout.Bytes(), fmt.Errorf("git %s: %w: %s", what, err, w.red.Sanitize(msg)) - } - return stdout.Bytes(), nil -} - -func realPath(p string) string { - if r, err := filepath.EvalSymlinks(p); err == nil { - return r - } - return filepath.Clean(p) -} - -func samePath(a, b string) bool { - return a != "" && b != "" && realPath(a) == realPath(b) -} diff --git a/internal/connector/worktrees_route_test.go b/internal/connector/worktrees_route_test.go deleted file mode 100644 index c36a2feb0..000000000 --- a/internal/connector/worktrees_route_test.go +++ /dev/null @@ -1,66 +0,0 @@ -package connector - -import ( - "context" - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// Telling a route that is misconfigured from one that could not be read this -// once. The first is refused and said out loud; the second is waited on, as -// every Prepare failure used to be. - -func TestPrepareRefusesARouteThatIsNotARepository(t *testing.T) { - h := newWorktreeHarness(t) - outside := filepath.Join(filepath.Dir(h.repo), "no-repository-here") - require.NoError(t, os.MkdirAll(outside, 0o700)) - - _, err := h.wt.Prepare(context.Background(), outside, 1) - require.Error(t, err) - assert.ErrorIs(t, err, ErrRouteUnusable) - assert.Empty(t, h.wt.RoutesWaiting(), "a directory that will not become a repository on its own is not waited on") -} - -func TestPrepareRefusesARepositoryWithNoCommit(t *testing.T) { - h := newWorktreeHarness(t) - empty := filepath.Join(filepath.Dir(h.repo), "empty-repository") - require.NoError(t, os.MkdirAll(empty, 0o700)) - h.git(empty, "init", "-q", "-b", "main") - - _, err := h.wt.Prepare(context.Background(), empty, 2) - require.Error(t, err) - assert.ErrorIs(t, err, ErrRouteUnusable) - assert.Empty(t, h.wt.RoutesWaiting()) -} - -// git that will not run is not a route that is not a repository. The proof is -// the disk, not git's exit status, which is 128 either way. -func TestPrepareWaitsWhenGitCannotRun(t *testing.T) { - h := newWorktreeHarness(t) - route := filepath.Join(h.repo, "app") - wt := h.worktrees(filepath.Join(h.repo, "no-such-git")) - - _, err := wt.Prepare(context.Background(), route, 3) - require.Error(t, err) - assert.NotErrorIs(t, err, ErrRouteUnusable) - assert.Equal(t, []string{route}, wt.RoutesWaiting(), "a route whose git failed is tried again") -} - -// A directory that is not there proves nothing about repositories: it may be -// a volume that is not mounted yet. -func TestPrepareWaitsOnARouteThatIsNotThere(t *testing.T) { - h := newWorktreeHarness(t) - gone := filepath.Join(filepath.Dir(h.repo), "not-mounted") - - _, err := h.wt.Prepare(context.Background(), gone, 4) - require.Error(t, err) - assert.NotErrorIs(t, err, ErrRouteUnusable) - assert.Equal(t, []string{gone}, h.wt.RoutesWaiting()) -} - -// The case a directory that cannot be read makes is in -// worktrees_route_unix_test.go: only a Unix mode says it. diff --git a/internal/connector/worktrees_route_unix_test.go b/internal/connector/worktrees_route_unix_test.go deleted file mode 100644 index a4abccd0a..000000000 --- a/internal/connector/worktrees_route_unix_test.go +++ /dev/null @@ -1,39 +0,0 @@ -//go:build unix - -package connector - -import ( - "context" - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// A directory that could not be read proves nothing either. git says the -// same thing it says outside a repository — exit 128 — and this is the case -// the proof exists to keep out of the refusal. -// -// Unix only, and by a build tag rather than a check inside the test: a mode -// of 0o000 is what makes a directory unsearchable, and it is a Unix mode. -// Windows does not enforce it, so the fixture would not build the state the -// test is about. NoRepositoryAt itself runs everywhere and needs no tag: an -// error that is not "does not exist" is no proof whatever produced it. -func TestPrepareWaitsOnARouteThatCannotBeRead(t *testing.T) { - if os.Geteuid() == 0 { - t.Skip("root reads every directory, whatever its mode") - } - h := newWorktreeHarness(t) - locked := filepath.Join(filepath.Dir(h.repo), "locked") - route := filepath.Join(locked, "route") - require.NoError(t, os.MkdirAll(route, 0o700)) - require.NoError(t, os.Chmod(locked, 0o000)) - t.Cleanup(func() { _ = os.Chmod(locked, 0o700) }) - - _, err := h.wt.Prepare(context.Background(), route, 5) - require.Error(t, err) - assert.NotErrorIs(t, err, ErrRouteUnusable) - assert.Equal(t, []string{route}, h.wt.RoutesWaiting()) -} diff --git a/internal/connector/worktrees_test.go b/internal/connector/worktrees_test.go deleted file mode 100644 index 2038f38a2..000000000 --- a/internal/connector/worktrees_test.go +++ /dev/null @@ -1,2153 +0,0 @@ -package connector - -import ( - "context" - "errors" - "os" - "os/exec" - "path/filepath" - "strconv" - "strings" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/basecamp/basecamp-cli/internal/connector/admission" - "github.com/basecamp/basecamp-cli/internal/connector/driver" -) - -// worktreeHarness is a repository with a bare remote, a ledger, and -// Worktrees placing worktrees under a private root. -type worktreeHarness struct { - t *testing.T - home string - repo string - remote string - root string - ledger *Ledger - wt *Worktrees -} - -func newWorktreeHarness(t *testing.T) *worktreeHarness { - t.Helper() - if _, err := exec.LookPath("git"); err != nil { - t.Skip("git is not installed") - } - dir := t.TempDir() - h := &worktreeHarness{ - t: t, - home: filepath.Join(dir, "home"), - repo: filepath.Join(dir, "repo"), - remote: filepath.Join(dir, "remote.git"), - root: filepath.Join(dir, "state", "worktrees"), - ledger: newTestLedger(t), - } - require.NoError(t, os.MkdirAll(h.home, 0o700)) - require.NoError(t, os.MkdirAll(filepath.Join(h.repo, "app"), 0o700)) - require.NoError(t, os.MkdirAll(filepath.Dir(h.root), 0o700)) - h.git(dir, "init", "-q", "--bare", "-b", "main", h.remote) - h.git(h.repo, "init", "-q", "-b", "main") - h.write(h.repo, "app/README", "hello\n") - h.git(h.repo, "add", ".") - h.git(h.repo, "commit", "-q", "-m", "init") - h.git(h.repo, "remote", "add", "origin", h.remote) - h.git(h.repo, "push", "-q", "origin", "main") - h.wt = h.worktrees("") - return h -} - -func (h *worktreeHarness) worktrees(gitBinary string) *Worktrees { - h.t.Helper() - w, err := NewWorktrees(WorktreesOptions{ - Ledger: h.ledger, - Root: h.root, - Git: gitBinary, - Lookup: h.lookup, - }) - require.NoError(h.t, err) - return w -} - -func (h *worktreeHarness) lookup(k string) (string, bool) { - switch k { - case "HOME": - return h.home, true - case "PATH": - return os.Getenv("PATH"), true - } - return "", false -} - -func (h *worktreeHarness) git(dir string, args ...string) string { - h.t.Helper() - cmd := exec.CommandContext(context.Background(), "git", append([]string{"-c", "user.name=Test", "-c", "user.email=test@example.invalid", "-c", "commit.gpgsign=false"}, args...)...) - cmd.Dir = dir - cmd.Env = []string{"HOME=" + h.home, "PATH=" + os.Getenv("PATH"), "GIT_CONFIG_NOSYSTEM=1"} - out, err := cmd.CombinedOutput() - require.NoError(h.t, err, "git %v: %s", args, out) - return strings.TrimSpace(string(out)) -} - -// gitConflicting runs a git command that is meant to stop with a conflict: -// what it leaves in the record is the point, not its exit status. -func (h *worktreeHarness) gitConflicting(dir string, args ...string) { - h.t.Helper() - cmd := exec.CommandContext(context.Background(), "git", append([]string{"-c", "user.name=Test", "-c", "user.email=test@example.invalid", "-c", "commit.gpgsign=false"}, args...)...) - cmd.Dir = dir - cmd.Env = []string{"HOME=" + h.home, "PATH=" + os.Getenv("PATH"), "GIT_CONFIG_NOSYSTEM=1"} - out, err := cmd.CombinedOutput() - require.Error(h.t, err, "git %v was meant to conflict: %s", args, out) -} - -func (h *worktreeHarness) write(dir, name, content string) { - h.t.Helper() - require.NoError(h.t, os.MkdirAll(filepath.Dir(filepath.Join(dir, name)), 0o700)) - require.NoError(h.t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600)) -} - -// prepare makes a worktree for the route "app" and returns the working -// directory and its row. -func (h *worktreeHarness) prepare(eventID int64) (string, Worktree) { - h.t.Helper() - workDir, err := h.wt.Prepare(context.Background(), filepath.Join(h.repo, "app"), eventID) - require.NoError(h.t, err) - row := h.row(workDir) - return workDir, row -} - -func (h *worktreeHarness) row(workDir string) Worktree { - h.t.Helper() - rows, err := h.ledger.Worktrees(context.Background()) - require.NoError(h.t, err) - for _, r := range rows { - if r.WorkDir == workDir { - return r - } - } - h.t.Fatalf("no worktree row for %s", workDir) - return Worktree{} -} - -// discard is what an operator does: the task ends (which only ever keeps the -// worktree), then `worktrees prune` judges it and removes what holds nothing. -func (h *worktreeHarness) discard(workDir string) Worktree { - h.t.Helper() - require.NoError(h.t, h.wt.Finish(context.Background(), filepath.Join(h.repo, "app"), workDir)) - _, err := h.wt.Prune(context.Background(), nil) - require.NoError(h.t, err) - return h.row(workDir) -} - -func (h *worktreeHarness) finish(workDir string) Worktree { - h.t.Helper() - require.NoError(h.t, h.wt.Finish(context.Background(), filepath.Join(h.repo, "app"), workDir)) - return h.row(workDir) -} - -func (h *worktreeHarness) branchExists(branch string) bool { - h.t.Helper() - return h.git(h.repo, "for-each-ref", "refs/heads/"+branch) != "" -} - -func TestPrepareMakesAWorktreeOnATaskBranchOutsideTheCheckout(t *testing.T) { - h := newWorktreeHarness(t) - workDir, row := h.prepare(17) - - assert.Equal(t, WorktreeLive, row.State) - assert.Equal(t, filepath.Join(row.Path, "app"), workDir, "the route's place inside the repository") - assert.True(t, strings.HasPrefix(row.Path, h.root+string(filepath.Separator)), "placed under the connector's root") - assert.False(t, strings.HasPrefix(row.Path, h.repo), "never inside the checkout") - assert.True(t, strings.HasPrefix(row.Branch, BranchPrefix+"17-")) - assert.Equal(t, h.git(h.repo, "rev-parse", "HEAD"), row.BaseCommit) - assert.FileExists(t, filepath.Join(workDir, "README")) - assert.Empty(t, h.git(h.repo, "status", "--porcelain"), "the checkout sees nothing of it") - - info, err := os.Stat(filepath.Dir(row.Path)) - require.NoError(t, err) - assert.Equal(t, os.FileMode(0o700), info.Mode().Perm()) -} - -// Invariant 1: a task's end keeps its worktree, whatever is in it; the -// operator's prune is what removes one with nothing to lose, with its branch. -func TestATasksEndKeepsItsWorktreeAndAPruneRemovesIt(t *testing.T) { - h := newWorktreeHarness(t) - workDir, _ := h.prepare(1) - - kept := h.finish(workDir) - assert.Equal(t, WorktreeRetained, kept.State) - assert.Equal(t, RetainedFinished, kept.RetainedReason) - assert.True(t, exists(kept.Path), "the worktree is still there") - assert.True(t, h.branchExists(kept.Branch), "and so is its branch") - - row := h.discard(workDir) - assert.Equal(t, WorktreeRemoved, row.State) - assert.Equal(t, RemovedByPrune, row.RemovedBy) - assert.False(t, exists(row.Path)) - assert.False(t, h.branchExists(row.Branch)) -} - -// Invariant 1: uncommitted work survives the task's end and is listed as -// retained (the card's done-when). -func TestUncommittedWorkSurvivesTheTaskAndIsRetained(t *testing.T) { - for name, change := range map[string]func(h *worktreeHarness, workDir string){ - "modified": func(h *worktreeHarness, d string) { h.write(d, "README", "changed\n") }, - "untracked": func(h *worktreeHarness, d string) { h.write(d, "notes/new.txt", "draft\n") }, - "staged": func(h *worktreeHarness, d string) { - h.write(d, "staged.txt", "x\n") - h.git(d, "add", "staged.txt") - }, - "deleted": func(h *worktreeHarness, d string) { require.NoError(h.t, os.Remove(filepath.Join(d, "README"))) }, - "ignored": func(h *worktreeHarness, d string) { - exclude := h.git(d, "rev-parse", "--path-format=absolute", "--git-path", "info/exclude") - require.NoError(h.t, os.MkdirAll(filepath.Dir(exclude), 0o700)) - require.NoError(h.t, os.WriteFile(exclude, []byte("*.local\n"), 0o600)) - h.write(d, "report.local", "results\n") - }, - "skip-worktree": func(h *worktreeHarness, d string) { - h.git(d, "update-index", "--skip-worktree", "README") - h.write(d, "README", "hidden edit\n") - }, - "assume-unchanged": func(h *worktreeHarness, d string) { - h.git(d, "update-index", "--assume-unchanged", "README") - h.write(d, "README", "hidden edit\n") - }, - "merge in progress": func(h *worktreeHarness, d string) { - marker := h.git(d, "rev-parse", "--path-format=absolute", "--git-path", "MERGE_HEAD") - require.NoError(h.t, os.WriteFile(marker, []byte(h.git(d, "rev-parse", "HEAD")+"\n"), 0o600)) - }, - } { - t.Run(name, func(t *testing.T) { - h := newWorktreeHarness(t) - workDir, _ := h.prepare(2) - change(h, workDir) - row := h.finish(workDir) - assert.Equal(t, WorktreeRetained, row.State) - assert.Equal(t, RetainedFinished, row.RetainedReason) - // And an operator's prune keeps it too, now saying what is in it. - row = h.discard(workDir) - assert.Equal(t, WorktreeRetained, row.State) - assert.Equal(t, RetainedDirty, row.RetainedReason) - assert.True(t, exists(workDir)) - assert.True(t, h.branchExists(row.Branch)) - - retained, err := h.wt.Retained(context.Background()) - require.NoError(t, err) - require.Len(t, retained, 1) - assert.Equal(t, row.Path, retained[0].Path) - }) - } -} - -// Invariant 1: a commit only this worktree holds keeps it; one a remote or -// the main line holds does not; another task's branch is no evidence. -func TestCommitsAreKeptUntilHeldElsewhere(t *testing.T) { - commit := func(h *worktreeHarness, d, name string) string { - h.write(d, name, name+"\n") - h.git(d, "add", name) - h.git(d, "commit", "-q", "-m", name) - return h.git(d, "rev-parse", "HEAD") - } - t.Run("unpushed", func(t *testing.T) { - h := newWorktreeHarness(t) - workDir, _ := h.prepare(3) - commit(h, workDir, "work.txt") - row := h.discard(workDir) - assert.Equal(t, RetainedUnpushed, row.RetainedReason) - assert.True(t, exists(workDir)) - }) - t.Run("pushed", func(t *testing.T) { - h := newWorktreeHarness(t) - workDir, row := h.prepare(4) - commit(h, workDir, "work.txt") - h.git(workDir, "push", "-q", "origin", row.Branch) - row = h.discard(workDir) - assert.Equal(t, WorktreeRemoved, row.State) - assert.False(t, h.branchExists(row.Branch)) - }) - t.Run("merged", func(t *testing.T) { - h := newWorktreeHarness(t) - workDir, row := h.prepare(5) - commit(h, workDir, "work.txt") - h.git(h.repo, "merge", "-q", "--ff-only", row.Branch) - row = h.discard(workDir) - assert.Equal(t, WorktreeRemoved, row.State) - }) - t.Run("held only by another task's branch", func(t *testing.T) { - h := newWorktreeHarness(t) - workDir, _ := h.prepare(6) - sha := commit(h, workDir, "work.txt") - h.git(h.repo, "branch", BranchPrefix+"99-other", sha) - row := h.discard(workDir) - assert.Equal(t, RetainedUnpushed, row.RetainedReason) - }) - t.Run("detached away from an unpushed branch", func(t *testing.T) { - h := newWorktreeHarness(t) - workDir, row := h.prepare(7) - commit(h, workDir, "work.txt") - h.git(workDir, "checkout", "-q", "--detach", row.BaseCommit) - row = h.discard(workDir) - assert.Equal(t, RetainedUnpushed, row.RetainedReason, "the task branch's commits count, wherever HEAD is") - }) -} - -func TestALockedWorktreeIsRetained(t *testing.T) { - h := newWorktreeHarness(t) - workDir, row := h.prepare(8) - h.git(h.repo, "worktree", "lock", row.Path) - row = h.discard(workDir) - assert.Equal(t, RetainedLocked, row.RetainedReason) - assert.True(t, exists(workDir)) -} - -// fakeGit is a git that runs the real one, except where told to fail or to -// do something first. -func fakeGit(t *testing.T, script string) string { - t.Helper() - gitPath, err := exec.LookPath("git") - require.NoError(t, err) - path := filepath.Join(t.TempDir(), "git") - body := "#!/bin/sh\nREAL=" + gitPath + "\n" + script + "\nexec \"$REAL\" \"$@\"\n" - require.NoError(t, os.WriteFile(path, []byte(body), 0o700)) - return path -} - -// Invariant 1: a check that fails keeps the worktree. -func TestAFailedCheckRetains(t *testing.T) { - h := newWorktreeHarness(t) - workDir, _ := h.prepare(9) - h.wt = h.worktrees(fakeGit(t, `for a in "$@"; do [ "$a" = status ] && exit 128; done`)) - row := h.discard(workDir) - assert.Equal(t, WorktreeRetained, row.State) - assert.Equal(t, RetainedUnverified, row.RetainedReason) - assert.True(t, exists(workDir)) -} - -// Invariant 2: the branch is deleted only while it still points at the commit -// that was verified. -func TestABranchThatMovedIsNotDeleted(t *testing.T) { - h := newWorktreeHarness(t) - workDir, row := h.prepare(11) - // Between the check and the branch's deletion someone commits onto the - // task branch from elsewhere. - other := filepath.Join(t.TempDir(), "other") - h.git(h.repo, "worktree", "add", "-q", "--detach", other, row.BaseCommit) - h.write(other, "moved.txt", "x\n") - h.git(other, "add", "moved.txt") - h.git(other, "commit", "-q", "-m", "moved") - moved := h.git(other, "rev-parse", "HEAD") - h.wt = h.worktrees(fakeGit(t, `case "$*" in *"update-ref --stdin"*) "$REAL" -C "`+h.repo+`" update-ref refs/heads/`+row.Branch+` `+moved+`;; esac`)) - row = h.discard(workDir) - // The judgment no longer stands, so the worktree is kept with it. - assert.Equal(t, WorktreeRetained, row.State) - assert.Equal(t, moved, h.git(h.repo, "rev-parse", "refs/heads/"+row.Branch)) - assert.True(t, exists(workDir), "the worktree is still there") -} - -// Invariant 6: the repository's hooks do not run. -func TestTheRepositorysHooksDoNotRun(t *testing.T) { - h := newWorktreeHarness(t) - marker := filepath.Join(t.TempDir(), "hook-ran") - hook := filepath.Join(h.repo, ".git", "hooks", "post-checkout") - require.NoError(t, os.WriteFile(hook, []byte("#!/bin/sh\ntouch "+marker+"\n"), 0o700)) - h.prepare(12) - assert.False(t, exists(marker)) -} - -// Invariant 1, on the disk itself: a file in a submodule's directory, which -// git neither reports nor refuses to remove, is work. -func TestWorkInASubmodulesDirectoryIsRetained(t *testing.T) { - h := newWorktreeHarness(t) - sub := filepath.Join(t.TempDir(), "sub") - require.NoError(t, os.MkdirAll(sub, 0o700)) - h.git(sub, "init", "-q", "-b", "main") - h.write(sub, "lib.txt", "lib\n") - h.git(sub, "add", ".") - h.git(sub, "commit", "-q", "-m", "sub") - h.git(h.repo, "-c", "protocol.file.allow=always", "submodule", "add", "-q", sub, "app/vendor") - h.git(h.repo, "commit", "-q", "-m", "submodule") - - workDir, _ := h.prepare(14) - h.write(workDir, "vendor/notes.txt", "notes\n") - row := h.discard(workDir) - assert.Equal(t, RetainedDirty, row.RetainedReason) - assert.True(t, exists(filepath.Join(workDir, "vendor", "notes.txt"))) -} - -// Invariant 1: a commit only the worktree's reflog still reaches is work. -func TestACommitOnlyTheReflogReachesIsRetained(t *testing.T) { - h := newWorktreeHarness(t) - workDir, row := h.prepare(15) - h.git(workDir, "checkout", "-q", "--detach") - h.write(workDir, "c.txt", "c\n") - h.git(workDir, "add", "c.txt") - h.git(workDir, "commit", "-q", "-m", "moved away from") - h.git(workDir, "checkout", "-q", row.Branch) - row = h.discard(workDir) - assert.Equal(t, RetainedUnpushed, row.RetainedReason) -} - -// Invariant 6: a filter whose name git's -c could not carry, or that only -// the task branch's configuration defines, does not run either. -func TestFiltersOutOfReachOfAScanStillDoNotRun(t *testing.T) { - for name, configure := range map[string]func(h *worktreeHarness, marker string){ - "name with =": func(h *worktreeHarness, marker string) { - h.write(h.repo, ".gitattributes", "*.txt filter=a=b\n") - h.git(h.repo, "config", "filter.a=b.smudge", "touch "+marker+"; cat") - }, - "defined on the task branch": func(h *worktreeHarness, marker string) { - h.write(h.repo, ".gitattributes", "*.txt filter=probe\n") - include := filepath.Join(h.home, "branch-filter.gitconfig") - h.write(h.home, "branch-filter.gitconfig", "[filter \"probe\"]\n\tsmudge = touch "+marker+"; cat\n") - h.git(h.repo, "config", "includeIf.onbranch:"+BranchPrefix+"**.path", include) - }, - } { - t.Run(name, func(t *testing.T) { - h := newWorktreeHarness(t) - marker := filepath.Join(t.TempDir(), "ran") - configure(h, marker) - h.write(h.repo, "app/data.txt", "data\n") - h.git(h.repo, "add", ".") - h.git(h.repo, "commit", "-q", "-m", "attributes") - h.prepare(16) - assert.False(t, exists(marker), "no filter ran") - }) - } -} - -// Invariant 6, at removal: git worktree remove reads the worktree's files -// under the task branch's own configuration, and a filter defined there does -// not run either. -func TestAFilterOnTheTaskBranchDoesNotRunAtRemoval(t *testing.T) { - h := newWorktreeHarness(t) - marker := filepath.Join(t.TempDir(), "ran") - h.write(h.repo, ".gitattributes", "*.txt filter=probe\n") - h.write(h.repo, "app/data.txt", "data\n") - h.git(h.repo, "add", ".") - h.git(h.repo, "commit", "-q", "-m", "attributes") - workDir, _ := h.prepare(96) - h.write(h.home, "branch-filter.gitconfig", "[filter \"probe\"]\n\tclean = touch "+marker+"; cat\n\tsmudge = touch "+marker+"; cat\n") - h.git(h.repo, "config", "includeIf.onbranch:"+BranchPrefix+"**.path", filepath.Join(h.home, "branch-filter.gitconfig")) - // A racy index entry makes status read the file through its clean filter. - require.NoError(t, os.Chtimes(filepath.Join(workDir, "data.txt"), time.Now().Add(time.Hour), time.Now().Add(time.Hour))) - - row := h.discard(workDir) - assert.False(t, exists(marker), "no filter ran") - assert.Equal(t, WorktreeRemoved, row.State) -} - -// A filter git lfs marks required does not break the checkout once blanked. -func TestARequiredFilterDoesNotBreakTheCheckout(t *testing.T) { - h := newWorktreeHarness(t) - h.write(h.repo, ".gitattributes", "*.bin filter=lfsish\n") - h.write(h.repo, "app/blob.bin", "blob\n") - h.git(h.repo, "add", ".") - h.git(h.repo, "commit", "-q", "-m", "blob") - h.git(h.repo, "config", "filter.lfsish.smudge", "cat") - h.git(h.repo, "config", "filter.lfsish.clean", "cat") - h.git(h.repo, "config", "filter.lfsish.required", "true") - - workDir, row := h.prepare(97) - assert.Equal(t, WorktreeLive, row.State) - assert.FileExists(t, filepath.Join(workDir, "blob.bin")) - assert.Equal(t, WorktreeRemoved, h.discard(workDir).State) -} - -// submoduleHarness is a worktree harness whose repository has a submodule at -// app/vendor, and the submodule's source. -func submoduleHarness(t *testing.T) (*worktreeHarness, string) { - t.Helper() - h := newWorktreeHarness(t) - sub := filepath.Join(t.TempDir(), "sub") - require.NoError(t, os.MkdirAll(sub, 0o700)) - h.git(sub, "init", "-q", "-b", "main") - h.write(sub, "lib.txt", "lib\n") - h.git(sub, "add", ".") - h.git(sub, "commit", "-q", "-m", "sub") - h.git(h.repo, "-c", "protocol.file.allow=always", "submodule", "add", "-q", sub, "app/vendor") - h.git(h.repo, "commit", "-q", "-m", "submodule") - return h, sub -} - -// Invariant 6 in a submodule's directory: a git directory and configuration a -// worker planted there never make the connector's git run a filter, because -// git is never asked to look inside it. -func TestAFilterPlantedInASubmoduleDoesNotRun(t *testing.T) { - h, sub := submoduleHarness(t) - workDir, _ := h.prepare(98) - marker := filepath.Join(t.TempDir(), "ran") - vendor := filepath.Join(workDir, "vendor") - clone := filepath.Join(t.TempDir(), "clone") - h.git(filepath.Dir(clone), "clone", "-q", sub, clone) - require.NoError(t, os.Rename(filepath.Join(clone, ".git"), filepath.Join(vendor, ".planted"))) - require.NoError(t, os.Rename(filepath.Join(clone, "lib.txt"), filepath.Join(vendor, "lib.txt"))) - h.write(vendor, ".git", "gitdir: .planted\n") - h.write(vendor, ".planted/info/attributes", "lib.txt filter=probe\n") - h.git(vendor, "config", "filter.probe.clean", "touch "+marker+"; cat") - require.NoError(t, os.Chtimes(filepath.Join(vendor, "lib.txt"), time.Now().Add(time.Hour), time.Now().Add(time.Hour))) - - row := h.discard(workDir) - assert.False(t, exists(marker), "no filter ran") - assert.Equal(t, RetainedDirty, row.RetainedReason) -} - -// Invariant 5 with a submodule: its commits live in git directories a forced -// removal would delete, so a worktree holding any is not forced. -func TestAForcedPruneKeepsASubmodulesCommits(t *testing.T) { - h, _ := submoduleHarness(t) - workDir, _ := h.prepare(99) - h.git(workDir, "-c", "protocol.file.allow=always", "submodule", "update", "-q", "--init") - vendor := filepath.Join(workDir, "vendor") - h.write(vendor, "more.txt", "more\n") - h.git(vendor, "add", ".") - h.git(vendor, "commit", "-q", "-m", "only copy") - subGitDir := h.git(vendor, "rev-parse", "--absolute-git-dir") - row := h.discard(workDir) - - results, err := h.wt.Prune(context.Background(), []string{row.Path}) - require.NoError(t, err) - require.Len(t, results, 1) - assert.Equal(t, PruneKept, results[0].Action) - assert.True(t, results[0].ForceRefused) - assert.DirExists(t, subGitDir) -} - -// A checkout that never happened leaves nothing kept: an empty worktree is -// not work, and keeping it as dirty at every retry would fill the disk. -// A checkout that never happened is still not the connector's to delete: the -// row is kept, and the operator's prune removes it as a worktree holding -// nothing. -func TestAnUnpopulatedWorktreeIsKeptUntilAPrune(t *testing.T) { - h := newWorktreeHarness(t) - h.wt = h.worktrees(fakeGit(t, `case "$*" in *"reset --quiet --hard"*) exit 128;; esac`)) - _, err := h.wt.Prepare(context.Background(), filepath.Join(h.repo, "app"), 100) - require.Error(t, err) - rows, err := h.ledger.Worktrees(context.Background()) - require.NoError(t, err) - require.Len(t, rows, 1) - assert.Equal(t, WorktreeRetained, rows[0].State) - assert.True(t, exists(rows[0].Path)) - - h.wt = h.worktrees("") - results, err := h.wt.Prune(context.Background(), nil) - require.NoError(t, err) - require.Len(t, results, 1) - assert.Equal(t, PruneRemoved, results[0].Action) - assert.False(t, exists(rows[0].Path)) - assert.False(t, h.branchExists(rows[0].Branch)) -} - -// A worktree whose directory was deleted is recorded missing, and the -// repository's own record of it is left for git: it can hold a submodule's -// only commits, a reflog, or a lock for a directory that is only away. -func TestAMissingWorktreesRepositoryRecordIsLeftAlone(t *testing.T) { - h, _ := submoduleHarness(t) - workDir, row := h.prepare(103) - h.git(workDir, "-c", "protocol.file.allow=always", "submodule", "update", "-q", "--init") - vendor := filepath.Join(workDir, "vendor") - h.write(vendor, "more.txt", "more\n") - h.git(vendor, "add", ".") - h.git(vendor, "commit", "-q", "-m", "only copy") - subGitDir := h.git(vendor, "rev-parse", "--absolute-git-dir") - require.NoError(t, os.RemoveAll(row.Path)) - - row = h.discard(workDir) - assert.Equal(t, WorktreeRetained, row.State, "a record holding a submodule's commits keeps the row") - assert.DirExists(t, row.AdminDir) - assert.DirExists(t, subGitDir, "the submodule's only commits survive") -} - -// A worktree someone moved is kept, not forgotten: its files are still -// somewhere, and the connector cannot judge them where it cannot find them. -func TestAMovedWorktreeIsKept(t *testing.T) { - h := newWorktreeHarness(t) - workDir, row := h.prepare(90) - moved := filepath.Join(t.TempDir(), "moved") - h.git(h.repo, "worktree", "move", row.Path, moved) - require.False(t, exists(workDir)) - - row = h.discard(workDir) - assert.Equal(t, WorktreeRetained, row.State) - assert.Equal(t, RetainedMoved, row.RetainedReason) - assert.True(t, h.branchExists(row.Branch), "the branch the moved worktree has checked out") - assert.FileExists(t, filepath.Join(moved, "app", "README")) -} - -// A worktree moved with a detached HEAD is kept too: the repository's own -// record of it, not its branch, is what says where it is. -func TestAMovedWorktreeWithNoBranchIsKept(t *testing.T) { - h := newWorktreeHarness(t) - workDir, row := h.prepare(92) - h.git(workDir, "checkout", "-q", "--detach") - h.git(h.repo, "branch", "-q", "-D", row.Branch) - moved := filepath.Join(t.TempDir(), "moved") - h.git(h.repo, "worktree", "move", row.Path, moved) - - row = h.discard(workDir) - assert.Equal(t, WorktreeRetained, row.State) - assert.FileExists(t, filepath.Join(moved, "app", "README")) -} - -// A worktree moved and then deleted is gone, not kept forever. -func TestAMovedWorktreeThatIsThenDeletedIsGone(t *testing.T) { - h := newWorktreeHarness(t) - workDir, row := h.prepare(91) - moved := filepath.Join(t.TempDir(), "moved") - h.git(h.repo, "worktree", "move", row.Path, moved) - require.NoError(t, os.RemoveAll(moved)) - - row = h.discard(workDir) - assert.Equal(t, WorktreeRetained, row.State) - assert.Equal(t, RetainedOrphaned, row.RetainedReason, "the connector deletes none of what is left") - assert.True(t, h.branchExists(row.Branch)) - - results, err := h.wt.Prune(context.Background(), []string{row.Path}) - require.NoError(t, err) - require.Len(t, results, 1) - assert.Equal(t, PruneForced, results[0].Action, "the operator names it and is told what goes") - assert.False(t, h.branchExists(row.Branch)) - assert.Equal(t, WorktreeRemoved, h.row(workDir).State) -} - -// Invariant 1: a task branch the connector did not create is never deleted, -// however that worktree ends. -func TestABranchTheConnectorDidNotMakeIsNotDeleted(t *testing.T) { - h := newWorktreeHarness(t) - ctx := context.Background() - base := h.git(h.repo, "rev-parse", "HEAD") - branch := BranchPrefix + "80-taken" - h.git(h.repo, "branch", branch, base) - - record := Worktree{ - Path: filepath.Join(h.root, "repo", "80-taken"), WorkDir: filepath.Join(h.root, "repo", "80-taken"), - Route: filepath.Join(h.repo, "app"), Repository: h.repo, Branch: branch, BaseCommit: base, - OriginatingEventID: 80, State: WorktreeCreating, - } - id, err := h.ledger.BeginWorktree(ctx, record) - require.NoError(t, err) - record.ID = id - require.Error(t, h.wt.add(ctx, &record), "the branch is already someone's") - - unlock, err := h.wt.lock(ctx) - require.NoError(t, err) - settled := h.wt.settle(ctx, record) - unlock() - assert.Equal(t, WorktreeRemoved, settled.State) - assert.True(t, h.branchExists(branch), "someone else's branch survives") - assert.False(t, h.row(record.WorkDir).BranchCreated) -} - -// A worktree that cannot be made is not attempted again at every dispatch -// tick: each failure leaves a row and maybe a partial checkout. -func TestAFailedPrepareBacksOff(t *testing.T) { - h := newWorktreeHarness(t) - h.wt = h.worktrees(fakeGit(t, `case "$*" in *"worktree add"*) exit 128;; esac`)) - clock := time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC) - h.wt.now = func() time.Time { return clock } - route := filepath.Join(h.repo, "app") - ctx := context.Background() - - _, err := h.wt.Prepare(ctx, route, 70) - require.Error(t, err) - _, err = h.wt.Prepare(ctx, route, 70) - require.ErrorIs(t, err, ErrPrepareBackoff) - rows, err := h.ledger.Worktrees(ctx) - require.NoError(t, err) - assert.Len(t, rows, 1, "the second call made nothing") - - clock = clock.Add(PrepareBackoff) - _, err = h.wt.Prepare(ctx, route, 70) - require.Error(t, err) - assert.NotErrorIs(t, err, ErrPrepareBackoff) - clock = clock.Add(PrepareBackoff) - _, err = h.wt.Prepare(ctx, route, 70) - require.ErrorIs(t, err, ErrPrepareBackoff, "the wait doubles") - - _, err = h.wt.Prepare(ctx, route, 71) - require.ErrorIs(t, err, ErrPrepareBackoff, "the route waits, whichever event asks") - assert.Equal(t, []string{route}, h.wt.RoutesWaiting()) - - clock = clock.Add(PrepareBackoffMax) - assert.Empty(t, h.wt.RoutesWaiting(), "a route whose wait is over is not held") -} - -// A route that cannot take a worktree never fills the window the dispatcher -// starts records from: a healthy route's record still starts. -func TestAFailingWorktreeRouteDoesNotStarveTheOthers(t *testing.T) { - h := newWorktreeHarness(t) - broken := filepath.Join(t.TempDir(), "not-a-repository") - require.NoError(t, os.MkdirAll(broken, 0o700)) - healthy := filepath.Join(h.repo, "app") - const brokenBucket = 777 - fake := newFakeDriver() - d := newDispatchHarness(t, fake, func(o *DispatcherOptions) { - o.Ledger = h.ledger - o.Workspaces = h.wt - }) - d.ledger = h.ledger - d.mu.Lock() - d.routes = map[int64]admission.Route{adapterBucketID: {Path: healthy}, brokenBucket: {Path: broken}} - d.mu.Unlock() - admit := func(id, bucket int64, route string) { - event := testEvent(id) - event.BucketID = bucket - _, err := h.ledger.RecordSeen(context.Background(), event, LanePoll) - require.NoError(t, err) - v := admittedVerdict(id, 0, "recording:"+strconv.FormatInt(id, 10)) - v.BucketID, v.Route = bucket, route - _, err = h.ledger.Admission().Commit(context.Background(), v) - require.NoError(t, err) - } - for id := int64(100); id < 110; id++ { - admit(id, brokenBucket, broken) - } - admit(200, adapterBucketID, healthy) - d.run(t) - select { - case s := <-fake.made: - assert.True(t, strings.HasPrefix(s.cfg.Cwd, h.root), "the healthy route's record started in its worktree") - case <-time.After(10 * time.Second): - t.Fatal("a route that cannot take a worktree starved a healthy one") - } -} - -// With worktrees off, a new task works in its route, and a worktree made -// while they were on is still recovered. -func TestWorktreesOffStillRecoversWhatWasMade(t *testing.T) { - h := newWorktreeHarness(t) - ctx := context.Background() - workDir, _ := h.prepare(72) - h.write(workDir, "wip.txt", "wip\n") - - off, err := NewWorktrees(WorktreesOptions{Ledger: h.ledger, Root: h.root, Lookup: h.lookup, Off: true}) - require.NoError(t, err) - assert.False(t, off.PerTaskDirs()) - route := filepath.Join(h.repo, "app") - dir, err := off.Prepare(ctx, route, 73) - require.NoError(t, err) - assert.Equal(t, route, dir) - require.NoError(t, off.Finish(ctx, route, route)) - - require.NoError(t, off.Recover(ctx)) - assert.Equal(t, RetainedFinished, h.row(workDir).RetainedReason) - assert.True(t, exists(filepath.Join(workDir, "wip.txt")), "the work is where it was") -} - -// Invariant 6: a content filter the repository's configuration defines does -// not run when the connector checks out or inspects a worktree. -func TestConfiguredContentFiltersDoNotRun(t *testing.T) { - h := newWorktreeHarness(t) - markers := t.TempDir() - h.write(h.repo, ".gitattributes", "*.txt filter=probe\n") - h.write(h.repo, "app/data.txt", "data\n") - h.git(h.repo, "add", ".") - h.git(h.repo, "commit", "-q", "-m", "attributes") - h.git(h.repo, "config", "filter.probe.smudge", "touch "+filepath.Join(markers, "smudge")+"; cat") - h.git(h.repo, "config", "filter.probe.clean", "touch "+filepath.Join(markers, "clean")+"; cat") - - workDir, _ := h.prepare(13) - h.write(workDir, "data.txt", "changed\n") - row := h.discard(workDir) - assert.Equal(t, RetainedDirty, row.RetainedReason) - entries, err := os.ReadDir(markers) - require.NoError(t, err) - assert.Empty(t, entries, "no filter ran") -} - -// Invariant 3: every row a crash can leave is settled on the next start under -// the same rules, and a worktree a live task works in is not touched. -func TestRecoverSettlesWhatACrashLeft(t *testing.T) { - h := newWorktreeHarness(t) - ctx := context.Background() - - // Crashed before git ran: a row and nothing on disk. - never := Worktree{Path: filepath.Join(h.root, "x", "20-aaaaaa"), WorkDir: filepath.Join(h.root, "x", "20-aaaaaa", "app"), Route: filepath.Join(h.repo, "app"), - Repository: h.repo, Branch: BranchPrefix + "20-aaaaaa", BaseCommit: h.git(h.repo, "rev-parse", "HEAD"), OriginatingEventID: 20} - neverID, err := h.ledger.BeginWorktree(ctx, never) - require.NoError(t, err) - - // Crashed between git and live, with work in it. - dirtyDir, dirty := h.prepare(21) - h.write(dirtyDir, "wip.txt", "wip\n") - // Crashed mid-removal of a clean one (its names were not frozen). - cleanDir, clean := h.prepare(22) - require.NoError(t, h.ledger.MoveWorktree(ctx, clean.ID, WorktreeRemoving, WorktreeLive)) - // A live task still works in this one. - liveDir, _ := h.prepare(23) - admitOn(t, h.ledger, 23, "recording:23") - _, err = h.ledger.LaunchTask(ctx, LaunchSpec{EventID: 23, Route: testRoute, WorkDir: liveDir, Driver: "fake"}) - require.NoError(t, err) - - require.NoError(t, h.wt.Recover(ctx)) - - rows, err := h.ledger.Worktrees(ctx) - require.NoError(t, err) - byID := map[int64]Worktree{} - for _, r := range rows { - byID[r.ID] = r - } - // Nothing on disk to keep: the row is reconciled, nothing is deleted. - assert.Equal(t, RemovedNeverCreated, byID[neverID].RemovedBy) - // Everything that is on disk is kept, whatever is in it. - assert.Equal(t, RetainedFinished, byID[dirty.ID].RetainedReason) - assert.True(t, exists(filepath.Join(dirtyDir, "wip.txt"))) - assert.Equal(t, WorktreeRetained, byID[clean.ID].State) - assert.True(t, exists(cleanDir), "a clean worktree a crash left is kept too") - assert.Equal(t, WorktreeLive, h.row(liveDir).State) -} - -// Invariant 4: a check-and-remove waits for the lock another remover holds. -func TestRemovalsTakeTheWorktreesLock(t *testing.T) { - h := newWorktreeHarness(t) - workDir, _ := h.prepare(30) - unlock, err := h.wt.lock(context.Background()) - require.NoError(t, err) - ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) - defer cancel() - err = h.wt.Finish(ctx, filepath.Join(h.repo, "app"), workDir) - require.ErrorIs(t, err, context.DeadlineExceeded) - // A worktree that could not be judged is kept, and said to be: a row left - // live is a directory nothing lists and no prune touches. - row := h.row(workDir) - assert.Equal(t, WorktreeRetained, row.State) - assert.Equal(t, RetainedUnverified, row.RetainedReason) - unlock() - retained, err := h.wt.Retained(context.Background()) - require.NoError(t, err) - require.Len(t, retained, 1) - assert.Equal(t, row.Path, retained[0].Path) - // And the prune that follows judges it as any other kept worktree. - results, err := h.wt.Prune(context.Background(), nil) - require.NoError(t, err) - require.Len(t, results, 1) - assert.Equal(t, PruneRemoved, results[0].Action) -} - -// Invariant 5: prune removes what the operator dealt with, keeps what still -// holds work, forces only what the operator names, and never reaches a -// worktree that is not retained. -func TestPruneRemovesOnlyWhatTheOperatorDealtWith(t *testing.T) { - h := newWorktreeHarness(t) - ctx := context.Background() - - dealtDir, dealt := h.prepare(40) - h.write(dealtDir, "done.txt", "x\n") - h.finish(dealtDir) - h.git(dealtDir, "add", "done.txt") - h.git(dealtDir, "commit", "-q", "-m", "done") - h.git(dealtDir, "push", "-q", "origin", dealt.Branch) - - keptDir, _ := h.prepare(41) - h.write(keptDir, "wip.txt", "wip\n") - h.finish(keptDir) - - goneDir, gone := h.prepare(42) - h.write(goneDir, "wip.txt", "wip\n") - h.finish(goneDir) - require.NoError(t, os.RemoveAll(gone.Path)) - - forcedDir, forced := h.prepare(43) - h.write(forcedDir, "c.txt", "c\n") - h.git(forcedDir, "add", "c.txt") - h.git(forcedDir, "commit", "-q", "-m", "c") - h.write(forcedDir, "wip.txt", "wip\n") - h.finish(forcedDir) - - liveDir, live := h.prepare(44) - h.write(liveDir, "wip.txt", "wip\n") - - _, err := h.wt.Prune(ctx, []string{live.Path}) - require.ErrorIs(t, err, ErrNotRetained, "a live worktree is never prune's") - assert.True(t, exists(filepath.Join(liveDir, "wip.txt"))) - assert.True(t, exists(filepath.Join(forcedDir, "wip.txt")), "a refused prune removes nothing") - - results, err := h.wt.Prune(ctx, []string{forced.Path}) - require.NoError(t, err) - actions := map[string]PruneResult{} - for _, r := range results { - actions[r.Worktree.Path] = r - } - require.Len(t, actions, 4) - assert.Equal(t, PruneRemoved, actions[dealt.Path].Action) - assert.False(t, exists(dealt.Path)) - assert.Equal(t, PruneKept, actions[h.row(keptDir).Path].Action) - assert.Equal(t, RetainedDirty, actions[h.row(keptDir).Path].Reason) - assert.True(t, exists(filepath.Join(keptDir, "wip.txt"))) - // A directory something outside the connector removed: said to be - // orphaned, and nothing of what it left is touched. - assert.Equal(t, PruneKept, actions[gone.Path].Action) - assert.Equal(t, RetainedOrphaned, actions[gone.Path].Reason) - assert.Equal(t, PruneForced, actions[forced.Path].Action) - assert.NotEmpty(t, actions[forced.Path].RetainedRefs, "an unpushed commit is kept under a ref") - for _, ref := range actions[forced.Path].RetainedRefs { - assert.NotEmpty(t, h.git(h.repo, "for-each-ref", ref), "the kept ref is there") - } - assert.False(t, exists(forced.Path)) - assert.Equal(t, WorktreeLive, h.row(liveDir).State) - assert.True(t, exists(filepath.Join(liveDir, "wip.txt"))) -} - -// Invariant 5: a forced prune keeps a commit only a detached HEAD holds, on a -// branch of its own. -func TestAForcedPruneKeepsADetachedHeadsCommit(t *testing.T) { - h := newWorktreeHarness(t) - workDir, _ := h.prepare(45) - h.git(workDir, "checkout", "-q", "--detach") - h.write(workDir, "c.txt", "c\n") - h.git(workDir, "add", "c.txt") - h.git(workDir, "commit", "-q", "-m", "detached") - commit := h.git(workDir, "rev-parse", "HEAD") - row := h.discard(workDir) - require.Equal(t, RetainedUnpushed, row.RetainedReason) - - results, err := h.wt.Prune(context.Background(), []string{row.Path}) - require.NoError(t, err) - require.Len(t, results, 1) - assert.Equal(t, PruneForced, results[0].Action) - require.NotEmpty(t, results[0].RetainedRefs) - assert.Contains(t, h.git(h.repo, "for-each-ref", "--format=%(objectname)", RetainedRefPrefix), commit) - assert.False(t, exists(row.Path)) -} - -// The card's done-when, through the dispatcher: a worker leaves uncommitted -// work, its task ends, and the worktree is retained and listed. -func TestADispatchedTasksUncommittedWorkIsRetained(t *testing.T) { - h := newWorktreeHarness(t) - route := filepath.Join(h.repo, "app") - fake := newFakeDriver() - fake.turn = func(s *fakeSession, _ int, _ string) (driver.PromptResult, error) { - require.NoError(t, os.WriteFile(filepath.Join(s.cfg.Cwd, "answer.txt"), []byte("work\n"), 0o600)) - return driver.PromptResult{Stop: driver.TurnEndTurn}, nil - } - d := newDispatchHarness(t, fake, func(o *DispatcherOptions) { - o.Ledger = h.ledger - o.Workspaces = h.wt - }) - d.ledger = h.ledger - d.routes = map[int64]admission.Route{adapterBucketID: {Path: route}} - for _, id := range []int64{50, 51} { - seenRecord(t, h.ledger, id) - v := admittedVerdict(id, 0, "recording:"+string(rune('a'+id-50))) - v.Route = route - _, err := h.ledger.Admission().Commit(context.Background(), v) - require.NoError(t, err) - } - stop := d.run(t) - a, b := <-fake.made, <-fake.made - assert.NotEqual(t, a.cfg.Cwd, b.cfg.Cwd, "two tasks on one route, each in its own worktree") - d.attemptsEnded(t, 2) - stop() - - retained, err := h.wt.Retained(context.Background()) - require.NoError(t, err) - require.Len(t, retained, 2) - for _, r := range retained { - assert.Equal(t, RetainedFinished, r.RetainedReason) - assert.NotZero(t, r.TaskID) - content, err := os.ReadFile(filepath.Join(r.WorkDir, "answer.txt")) - require.NoError(t, err) - assert.Equal(t, "work\n", string(content)) - } -} - -// Worktree states move along their edges only: nothing goes back to live, -// and nothing leaves removed. -func TestWorktreeStatesMoveAlongTheirEdgesOnly(t *testing.T) { - h := newWorktreeHarness(t) - ctx := context.Background() - _, row := h.prepare(60) - require.NoError(t, h.ledger.RetainWorktree(ctx, row.ID, RetainedDirty, WorktreeLive)) - _, err := h.ledger.db.ExecContext(ctx, `UPDATE worktrees SET state = 'live' WHERE id = ?`, row.ID) - require.Error(t, err) - require.NoError(t, h.ledger.RemovedWorktree(ctx, row.ID, RemovedMissing, WorktreeRetained)) - _, err = h.ledger.db.ExecContext(ctx, `UPDATE worktrees SET state = 'retained', removed_by = '', retained_reason = 'dirty' WHERE id = ?`, row.ID) - require.Error(t, err) - require.ErrorIs(t, h.ledger.MoveWorktree(ctx, row.ID, WorktreeRemoving, WorktreeRetained), ErrWorktreeState) -} - -// A path the connector cannot even look at is not proof that work is gone. -func TestAnUnreadablePathCountsAsThere(t *testing.T) { - dir := t.TempDir() - closed := filepath.Join(dir, "closed") - require.NoError(t, os.Mkdir(closed, 0o700)) - inside := filepath.Join(closed, "worktree") - require.NoError(t, os.Mkdir(inside, 0o700)) - require.NoError(t, os.Chmod(closed, 0o000)) - t.Cleanup(func() { _ = os.Chmod(closed, 0o700) }) - if _, err := os.Lstat(inside); err == nil { - t.Skip("this user can read through a closed directory") - } - - assert.True(t, exists(inside), "unreadable is not absent") - assert.False(t, exists(filepath.Join(dir, "never")), "absent is absent") -} - -// A moved worktree is found through the repository's record of it however -// that record spells the path. -func TestAMovedWorktreeIsFoundWithRelativePaths(t *testing.T) { - h := newWorktreeHarness(t) - h.git(h.repo, "config", "worktree.useRelativePaths", "true") - workDir, row := h.prepare(95) - moved := filepath.Join(t.TempDir(), "moved") - h.git(h.repo, "worktree", "move", row.Path, moved) - - row = h.discard(workDir) - assert.Equal(t, RetainedMoved, row.RetainedReason) - assert.True(t, h.branchExists(row.Branch)) - assert.FileExists(t, filepath.Join(moved, "app", "README")) -} - -// A moved worktree is not forced away either: there is nothing at the path to -// judge, and prune says so instead of trying. -func TestAMovedWorktreeIsNotForced(t *testing.T) { - h := newWorktreeHarness(t) - workDir, row := h.prepare(93) - moved := filepath.Join(t.TempDir(), "moved") - h.git(h.repo, "worktree", "move", row.Path, moved) - row = h.discard(workDir) - require.Equal(t, RetainedMoved, row.RetainedReason) - - results, err := h.wt.Prune(context.Background(), []string{row.Path}) - require.NoError(t, err) - require.Len(t, results, 1) - assert.Equal(t, PruneKept, results[0].Action) - assert.Equal(t, RetainedMoved, results[0].Reason) - assert.False(t, results[0].ForceRefused, "prune says where it is, it does not try and fail") - assert.FileExists(t, filepath.Join(moved, "app", "README")) -} - -// A branch the connector made for a worktree that then failed to appear is -// its own to clean up. -// A `worktree add` that failed leaves a branch and no directory. The -// connector deletes neither: the row is kept, and an operator's prune clears -// both once the branch reaches nothing that is not held. -func TestAFailedAddKeepsItsBranchUntilAPrune(t *testing.T) { - h := newWorktreeHarness(t) - h.wt = h.worktrees(fakeGit(t, `case "$*" in *"worktree add"*) exit 128;; esac`)) - _, err := h.wt.Prepare(context.Background(), filepath.Join(h.repo, "app"), 94) - require.Error(t, err) - rows, err := h.ledger.Worktrees(context.Background()) - require.NoError(t, err) - require.Len(t, rows, 1) - assert.True(t, rows[0].BranchCreated) - assert.Equal(t, WorktreeRetained, rows[0].State) - assert.True(t, h.branchExists(rows[0].Branch), "the branch it made is not the connector's to delete") - - h.wt = h.worktrees("") - results, err := h.wt.Prune(context.Background(), nil) - require.NoError(t, err) - require.Len(t, results, 1) - assert.Equal(t, PruneKept, results[0].Action) - assert.Equal(t, RetainedOrphaned, results[0].Reason) - assert.True(t, h.branchExists(rows[0].Branch), "a plain prune deletes nothing of it") - - forced, err := h.wt.Prune(context.Background(), []string{rows[0].Path}) - require.NoError(t, err) - require.Len(t, forced, 1) - assert.False(t, h.branchExists(rows[0].Branch), "the operator naming it clears it") -} - -// A removal the ledger could not record is still reported as a removal, and -// Finish says it was not recorded, rather than anyone being told it was kept. -func TestARemovalTheLedgerCouldNotRecordIsNotReportedKept(t *testing.T) { - h := newWorktreeHarness(t) - ctx := context.Background() - workDir, _ := h.prepare(104) - require.Equal(t, WorktreeRetained, h.finish(workDir).State) - _, err := h.ledger.db.ExecContext(ctx, `CREATE TRIGGER refuse_removed BEFORE UPDATE OF state ON worktrees -WHEN NEW.state = 'removed' BEGIN SELECT RAISE(ABORT, 'test: the ledger refuses'); END`) - require.NoError(t, err) - - results, err := h.wt.Prune(ctx, nil) - require.NoError(t, err) - require.Len(t, results, 1) - assert.Equal(t, PruneRemoved, results[0].Action, "gone is not reported kept") - row := h.row(workDir) - assert.Equal(t, WorktreeRemoving, row.State) - assert.False(t, exists(row.Path)) -} - -// A missing worktree whose record in the repository still reaches a commit -// nothing else holds is kept, so the operator hears of it before git's own -// prune takes it; the connector deletes nothing either way. -func TestAMissingWorktreeWhoseRecordHoldsACommitIsKept(t *testing.T) { - h := newWorktreeHarness(t) - workDir, row := h.prepare(105) - h.git(workDir, "checkout", "-q", "--detach") - h.write(workDir, "c.txt", "c\n") - h.git(workDir, "add", "c.txt") - h.git(workDir, "commit", "-q", "-m", "reflog only") - h.git(workDir, "checkout", "-q", row.Branch) - require.NoError(t, os.RemoveAll(row.Path)) - - row = h.discard(workDir) - assert.Equal(t, WorktreeRetained, row.State) - assert.DirExists(t, row.AdminDir) -} - -// A forced removal the ledger could not record is still reported forced. -func TestAForcedRemovalTheLedgerCouldNotRecordIsReportedForced(t *testing.T) { - h := newWorktreeHarness(t) - ctx := context.Background() - workDir, _ := h.prepare(106) - h.write(workDir, "wip.txt", "wip\n") - row := h.discard(workDir) - require.Equal(t, RetainedDirty, row.RetainedReason) - _, err := h.ledger.db.ExecContext(ctx, `CREATE TRIGGER refuse_removed BEFORE UPDATE OF state ON worktrees -WHEN NEW.state = 'removed' BEGIN SELECT RAISE(ABORT, 'test: the ledger refuses'); END`) - require.NoError(t, err) - - results, err := h.wt.Prune(ctx, []string{row.Path}) - require.NoError(t, err) - require.Len(t, results, 1) - assert.Equal(t, PruneForced, results[0].Action) - assert.False(t, results[0].ForceRefused) - assert.False(t, exists(row.Path)) -} - -// The worktree rule ("One worktree, one removal"), case by case: what counts -// as work, what happens to it, and that the check still holds when something -// tries to land work between the check and the removal. -func TestTheWorktreeRule(t *testing.T) { - commit := func(h *worktreeHarness, dir, name string) string { - h.write(dir, name, name+"\n") - h.git(dir, "add", name) - h.git(dir, "commit", "-q", "-m", name) - return h.git(dir, "rev-parse", "HEAD") - } - type rowT struct { - name string - // work makes the worktree's state; it returns a commit that must - // survive, if any. - work func(h *worktreeHarness, dir string, row Worktree) string - // frozen runs after the removal has frozen the worktree. - frozen func(t *testing.T, h *worktreeHarness, dir string, row Worktree) - force bool - // want is the row's state after; reason when retained. - want WorktreeState - reason RetainedReason - } - rows := []rowT{ - {name: "clean", want: WorktreeRemoved}, - {name: "modified file", work: func(h *worktreeHarness, d string, _ Worktree) string { h.write(d, "README", "x\n"); return "" }, want: WorktreeRetained, reason: RetainedDirty}, - {name: "untracked file", work: func(h *worktreeHarness, d string, _ Worktree) string { h.write(d, "new.txt", "x\n"); return "" }, want: WorktreeRetained, reason: RetainedDirty}, - {name: "ignored file", work: func(h *worktreeHarness, d string, _ Worktree) string { - exclude := h.git(d, "rev-parse", "--path-format=absolute", "--git-path", "info/exclude") - require.NoError(h.t, os.MkdirAll(filepath.Dir(exclude), 0o700)) - require.NoError(h.t, os.WriteFile(exclude, []byte("*.local\n"), 0o600)) - h.write(d, "notes.local", "x\n") - return "" - }, want: WorktreeRetained, reason: RetainedDirty}, - {name: "unpushed commit", work: func(h *worktreeHarness, d string, _ Worktree) string { return commit(h, d, "c.txt") }, want: WorktreeRetained, reason: RetainedUnpushed}, - {name: "commit only the reflog reaches", work: func(h *worktreeHarness, d string, row Worktree) string { - h.git(d, "checkout", "-q", "--detach") - sha := commit(h, d, "c.txt") - h.git(d, "checkout", "-q", row.Branch) - return sha - }, want: WorktreeRetained, reason: RetainedUnpushed}, - {name: "commit a per-worktree ref holds", work: func(h *worktreeHarness, d string, row Worktree) string { - sha := commit(h, d, "c.txt") - h.git(d, "update-ref", "refs/worktree/keep", sha) - h.git(d, "reset", "-q", "--hard", row.BaseCommit) - h.git(d, "reflog", "expire", "--expire=now", "--all") - return sha - }, want: WorktreeRetained, reason: RetainedUnpushed}, - {name: "stash", work: func(h *worktreeHarness, d string, _ Worktree) string { - h.write(d, "README", "stashed\n") - h.git(d, "stash", "-q") - return h.git(d, "rev-parse", "refs/stash") - }, want: WorktreeRemoved}, - {name: "locked", work: func(h *worktreeHarness, _ string, row Worktree) string { - h.git(h.repo, "worktree", "lock", row.Path) - return "" - }, want: WorktreeRetained, reason: RetainedLocked}, - {name: "a commit tried between the check and the removal", frozen: func(t *testing.T, h *worktreeHarness, dir string, row Worktree) { - for _, at := range []string{filepath.Join(row.Path, "app"), filepath.Join(dir, "app")} { - cmd := exec.CommandContext(context.Background(), "git", "-c", "user.name=T", "-c", "user.email=t@example.invalid", "commit", "-q", "--allow-empty", "-m", "late") - cmd.Dir = at - cmd.Env = []string{"HOME=" + h.home, "PATH=" + os.Getenv("PATH")} - assert.Error(t, cmd.Run(), "no commit lands in a frozen worktree (%s)", at) - } - }, want: WorktreeRemoved}, - {name: "a file written by path between the check and the removal", frozen: func(t *testing.T, _ *worktreeHarness, _ string, row Worktree) { - assert.Error(t, os.WriteFile(filepath.Join(row.Path, "app", "late.txt"), []byte("x"), 0o600), "the path does not reach a frozen worktree") - }, want: WorktreeRemoved}, - {name: "git's own prune while frozen", work: func(h *worktreeHarness, d string, row Worktree) string { - h.git(d, "checkout", "-q", "--detach") - sha := commit(h, d, "c.txt") - h.git(d, "checkout", "-q", row.Branch) - return sha - }, frozen: func(t *testing.T, h *worktreeHarness, _ string, _ Worktree) { - h.git(h.repo, "worktree", "prune", "--expire=now") - }, want: WorktreeRetained, reason: RetainedUnpushed}, - {name: "forced unpushed commit", work: func(h *worktreeHarness, d string, _ Worktree) string { return commit(h, d, "c.txt") }, force: true, want: WorktreeRemoved}, - {name: "forced commit only the reflog reaches", work: func(h *worktreeHarness, d string, row Worktree) string { - h.git(d, "checkout", "-q", "--detach") - sha := commit(h, d, "c.txt") - h.git(d, "checkout", "-q", row.Branch) - h.write(d, "wip.txt", "wip\n") - return sha - }, force: true, want: WorktreeRemoved}, - } - for _, tc := range rows { - t.Run(tc.name, func(t *testing.T) { - h := newWorktreeHarness(t) - ctx := context.Background() - workDir, row := h.prepare(300) - var keep string - if tc.work != nil { - keep = tc.work(h, workDir, row) - } - if tc.frozen != nil { - h.wt.whileFrozen = func(dir string) error { tc.frozen(t, h, dir, row); return nil } - } - // The task's end only ever keeps the worktree; the rule is what - // the operator's prune goes by. - var after Worktree - if tc.force { - h.wt.whileFrozen = nil - require.Equal(t, WorktreeRetained, h.finish(workDir).State) - results, err := h.wt.Prune(ctx, []string{row.Path}) - require.NoError(t, err) - require.Len(t, results, 1) - after = h.row(workDir) - } else { - require.Equal(t, WorktreeRetained, h.finish(workDir).State, "a task's end keeps its worktree") - after = h.discard(workDir) - } - assert.Equal(t, tc.want, after.State) - if tc.reason != "" { - assert.Equal(t, tc.reason, after.RetainedReason) - } - if tc.force && after.State == WorktreeRemoved { - assert.Equal(t, RemovedByPruneForced, after.RemovedBy) - } - if tc.want == WorktreeRetained { - assert.DirExists(t, workDir, "a kept worktree is where it was") - assert.NoDirExists(t, frozenName(row.Path)) - } else { - assert.NoDirExists(t, row.Path) - assert.NoDirExists(t, frozenName(row.Path)) - assert.NoDirExists(t, frozenName(row.AdminDir)) - } - if keep != "" { - assert.NoError(t, exec.CommandContext(ctx, "git", "-C", h.repo, "cat-file", "-e", keep+"^{commit}").Run()) - if tc.want == WorktreeRemoved { - refs := h.git(h.repo, "for-each-ref", "--contains", keep, "--format=%(refname)") - assert.NotEmpty(t, refs, "the commit is still reachable from a ref") - } - } - }) - } -} - -// A crash while a worktree is frozen leaves a removing row and frozen names; -// the next start restores them and judges again. -func TestACrashWhileFrozenIsRestoredOnTheNextStart(t *testing.T) { - h := newWorktreeHarness(t) - ctx := context.Background() - workDir, row := h.prepare(301) - h.write(workDir, "wip.txt", "wip\n") - require.Equal(t, WorktreeRetained, h.finish(workDir).State) - // The crash happens inside an operator's prune, the only thing that - // freezes a worktree. - h.wt.whileFrozen = func(string) error { return errors.New("crash") } - _, err := h.wt.Prune(ctx, nil) - require.NoError(t, err) - require.DirExists(t, frozenName(row.Path)) - require.Equal(t, WorktreeRemoving, h.row(workDir).State) - - h.wt.whileFrozen = nil - require.NoError(t, h.wt.Recover(ctx)) - after := h.row(workDir) - assert.Equal(t, WorktreeRetained, after.State) - assert.Equal(t, RetainedFinished, after.RetainedReason) - assert.FileExists(t, filepath.Join(workDir, "wip.txt")) - assert.DirExists(t, row.AdminDir) - assert.NoDirExists(t, frozenName(row.Path)) -} - -// A row whose record's place was never stored has it found, proven and stored -// before anything is renamed, so a crash while frozen is restored too. -func TestACrashWhileFrozenWithoutAStoredRecordIsRestored(t *testing.T) { - h := newWorktreeHarness(t) - ctx := context.Background() - workDir, row := h.prepare(302) - _, err := h.ledger.db.ExecContext(ctx, `UPDATE worktrees SET admin_dir = '' WHERE id = ?`, row.ID) - require.NoError(t, err) - h.write(workDir, "wip.txt", "wip\n") - require.Equal(t, WorktreeRetained, h.finish(workDir).State) - h.wt.whileFrozen = func(string) error { return errors.New("crash") } - _, err = h.wt.Prune(ctx, nil) - require.NoError(t, err) - require.NotEmpty(t, h.row(workDir).AdminDir, "stored before the freeze") - - h.wt.whileFrozen = nil - require.NoError(t, h.wt.Recover(ctx)) - after := h.row(workDir) - assert.Equal(t, RetainedFinished, after.RetainedReason) - assert.DirExists(t, row.AdminDir) - assert.NoFileExists(t, filepath.Join(row.AdminDir, "locked"), "the connector's lock goes with the freeze") -} - -// Invariant 3: configuration that verifies signatures does not make the -// connector's git run a program. -func TestSignatureVerificationDoesNotRun(t *testing.T) { - h := newWorktreeHarness(t) - marker := filepath.Join(t.TempDir(), "gpg-ran") - script := filepath.Join(t.TempDir(), "gpg") - require.NoError(t, os.WriteFile(script, []byte("#!/bin/sh\ntouch "+marker+"\nexit 1\n"), 0o700)) - h.git(h.repo, "config", "log.showSignature", "true") - h.git(h.repo, "config", "gpg.program", script) - workDir, row := h.prepare(303) - // A commit carrying a signature header, as a worker could hand-make. - tree := h.git(workDir, "rev-parse", "HEAD^{tree}") - body := "tree " + tree + "\nparent " + row.BaseCommit + "\nauthor T 1 +0000\ncommitter T 1 +0000\ngpgsig -----BEGIN PGP SIGNATURE-----\n \n -----END PGP SIGNATURE-----\n\nsigned\n" - obj := filepath.Join(t.TempDir(), "commit") - require.NoError(t, os.WriteFile(obj, []byte(body), 0o600)) - signed := h.git(workDir, "hash-object", "-t", "commit", "-w", obj) - h.git(workDir, "reset", "-q", "--soft", signed) - - h.discard(workDir) - assert.NoFileExists(t, marker, "no signature program ran") -} - -// Invariant 4: a task branch is deleted in one ref transaction with a check -// that its holder has not moved; a holder moved in between keeps the branch. -// What holds a worktree's commits while it is being deleted is the -// connector's own refs, not someone else's: a branch deleted between the -// transaction and the deletion takes nothing with it. -func TestAHolderThatGoesWhileTheRemovalRunsTakesNothingWithIt(t *testing.T) { - h := newWorktreeHarness(t) - workDir, row := h.prepare(310) - h.git(workDir, "checkout", "-q", "--detach") - h.write(workDir, "c.txt", "c\n") - h.git(workDir, "add", "c.txt") - h.git(workDir, "commit", "-q", "-m", "c") - sha := h.git(workDir, "rev-parse", "HEAD") - h.git(workDir, "checkout", "-q", row.Branch) - // Only this branch holds that commit, and it goes the moment the - // removal's transaction is through — while the directory and the record - // are being deleted. - h.git(h.repo, "branch", "keeper", sha) - h.wt = h.worktrees(fakeGit(t, `case "$*" in *"update-ref --stdin"*) "$REAL" "$@"; rc=$?; "$REAL" -C "`+h.repo+`" branch -D keeper >/dev/null 2>&1; exit $rc;; esac`)) - - after := h.discard(workDir) - assert.Equal(t, WorktreeRemoved, after.State) - assert.False(t, exists(row.Path)) - assert.NoError(t, exec.CommandContext(context.Background(), "git", "-C", h.repo, "cat-file", "-e", sha+"^{commit}").Run()) - refs := h.git(h.repo, "for-each-ref", "--contains", sha, "--format=%(refname)") - assert.Contains(t, refs, RemovingRefPrefix, "the commit is still held by a ref of the connector's own") -} - -// A repository a worker made is git data wherever it is, including the -// directory the task worked in: a force discards files, never commits. -func TestARepositoryMadeWhereGitTracksFilesIsNeverRemoved(t *testing.T) { - h := newWorktreeHarness(t) - workDir, row := h.prepare(321) - // A repository made where git tracks files: the worktree's own root, - // which the walk starts at and which is tracked by definition. - h.git(workDir, "init", "-q", "--bare", row.Path) - require.True(t, isGitDir(row.Path)) - require.Equal(t, WorktreeRetained, h.finish(workDir).State) - - results, err := h.wt.Prune(context.Background(), []string{row.Path}) - require.NoError(t, err) - require.Len(t, results, 1) - assert.Equal(t, PruneKept, results[0].Action) - assert.True(t, results[0].ForceRefused) - assert.True(t, exists(filepath.Join(row.Path, "objects")), "the repository a worker made is still there") -} - -// A row whose repository is gone: an operator who names it gets it closed, -// because there is nothing left anywhere to delete or to keep it for. -func TestAForceClosesARowWhoseRepositoryIsGone(t *testing.T) { - h := newWorktreeHarness(t) - workDir, row := h.prepare(322) - require.Equal(t, WorktreeRetained, h.finish(workDir).State) - require.NoError(t, os.RemoveAll(row.Path)) - require.NoError(t, os.RemoveAll(h.repo)) - - results, err := h.wt.Prune(context.Background(), []string{row.Path}) - require.NoError(t, err) - require.Len(t, results, 1) - assert.Equal(t, PruneForced, results[0].Action, "a row nothing can read is not a row nothing can close") - assert.Equal(t, WorktreeRemoved, h.row(workDir).State) -} - -// A row that never stored where git's record is does not get closed on the -// strength of not knowing: the repository is asked, and a record that is -// there keeps the row. -func TestARowThatDoesNotKnowWhereItsRecordIsKeepsIt(t *testing.T) { - h := newWorktreeHarness(t) - workDir, row := h.prepare(323) - require.Equal(t, WorktreeRetained, h.finish(workDir).State) - _, err := h.ledger.db.ExecContext(context.Background(), `UPDATE worktrees SET admin_dir = '' WHERE id = ?`, row.ID) - require.NoError(t, err) - // The directory and the branch go; git's record of the worktree stays. - require.NoError(t, os.RemoveAll(row.Path)) - h.git(h.repo, "update-ref", "-d", "refs/heads/"+row.Branch) - - results, err := h.wt.Prune(context.Background(), nil) - require.NoError(t, err) - require.Len(t, results, 1) - assert.Equal(t, PruneKept, results[0].Action) - assert.Equal(t, RetainedOrphaned, results[0].Reason, "the record is still there") -} - -// A force on an orphan says where the branch stood, because nothing worked -// out what it reached. -func TestAForcedOrphanSaysWhereItsBranchStood(t *testing.T) { - h := newWorktreeHarness(t) - workDir, row := h.prepare(324) - h.write(workDir, "c.txt", "c\n") - h.git(workDir, "add", "c.txt") - h.git(workDir, "commit", "-q", "-m", "c") - tip := h.git(workDir, "rev-parse", "HEAD") - require.Equal(t, WorktreeRetained, h.finish(workDir).State) - require.NoError(t, os.RemoveAll(row.Path)) - - results, err := h.wt.Prune(context.Background(), []string{row.Path}) - require.NoError(t, err) - require.Len(t, results, 1) - assert.Equal(t, PruneForced, results[0].Action) - assert.Equal(t, tip, results[0].BranchDeletedAt, "what an operator needs to put it back") - assert.False(t, h.branchExists(row.Branch)) - assert.NoError(t, exec.CommandContext(context.Background(), "git", "-C", h.repo, "cat-file", "-e", tip+"^{commit}").Run()) -} - -// A force on an orphan whose branch could not be deleted keeps the row: an -// operator is never left with something nothing lists. -func TestAnOrphanWhoseBranchStaysKeepsItsRow(t *testing.T) { - h := newWorktreeHarness(t) - workDir, row := h.prepare(320) - require.Equal(t, WorktreeRetained, h.finish(workDir).State) - require.NoError(t, os.RemoveAll(row.Path)) - h.wt = h.worktrees(fakeGit(t, `case "$*" in *"update-ref --stdin"*) exit 1;; esac`)) - - results, err := h.wt.Prune(context.Background(), []string{row.Path}) - require.NoError(t, err) - require.Len(t, results, 1) - assert.Equal(t, PruneKept, results[0].Action) - assert.Equal(t, RetainedOrphaned, results[0].Reason) - assert.True(t, h.branchExists(row.Branch)) - assert.Equal(t, WorktreeRetained, h.row(workDir).State, "still listed") -} - -// A pseudo-ref can name more than one commit — FETCH_HEAD does, and an -// octopus MERGE_HEAD does — and every one of them goes with the record. -func TestEveryCommitAPseudoRefNamesIsJudged(t *testing.T) { - h := newWorktreeHarness(t) - workDir, row := h.prepare(319) - first := h.git(h.repo, "rev-parse", "HEAD") - h.write(workDir, "c.txt", "c\n") - h.git(workDir, "add", "c.txt") - h.git(workDir, "commit", "-q", "-m", "c") - second := h.git(workDir, "rev-parse", "HEAD") - h.git(workDir, "reset", "-q", "--hard", row.BaseCommit) - h.git(workDir, "reflog", "expire", "--expire=now", "--all") - h.git(h.repo, "reflog", "expire", "--expire=now", "--all") - // Nothing else in the record names it: the reset's ORIG_HEAD goes. - origHead := h.git(workDir, "rev-parse", "--path-format=absolute", "--git-path", "ORIG_HEAD") - require.NoError(t, os.Remove(origHead)) - // A fetch's FETCH_HEAD: the held commit first, the unheld one after it. - fetchHead := h.git(workDir, "rev-parse", "--path-format=absolute", "--git-path", "FETCH_HEAD") - require.NoError(t, os.WriteFile(fetchHead, []byte(first+"\t\tbranch 'main' of origin\n"+second+"\tnot-for-merge\tbranch 'other' of origin\n"), 0o600)) - - after := h.discard(workDir) - assert.Equal(t, WorktreeRetained, after.State) - assert.Equal(t, RetainedUnpushed, after.RetainedReason, "the second name is judged too") - assert.NoError(t, exec.CommandContext(context.Background(), "git", "-C", h.repo, "cat-file", "-e", second+"^{commit}").Run()) -} - -// A bare repository a worker made inside its worktree is git data too: a -// force discards files, never commits, and no ref here could keep these. -func TestABareRepositoryTheWorkerMadeIsNeverRemoved(t *testing.T) { - h := newWorktreeHarness(t) - workDir, row := h.prepare(318) - bare := filepath.Join(workDir, "scratch.git") - require.NoError(t, os.MkdirAll(bare, 0o700)) - h.git(workDir, "init", "-q", "--bare", bare) - require.Equal(t, WorktreeRetained, h.finish(workDir).State) - - results, err := h.wt.Prune(context.Background(), []string{row.Path}) - require.NoError(t, err) - require.Len(t, results, 1) - assert.Equal(t, PruneKept, results[0].Action) - assert.True(t, results[0].ForceRefused, "a force does not discard a repository's git data") - assert.True(t, exists(bare)) -} - -// A commit only an old per-worktree ref's reflog reaches, in a repository -// that logs every ref: the reflog lives in the record and goes with it. -func TestACommitOnlyAPerWorktreeRefsReflogReachesIsKept(t *testing.T) { - h := newWorktreeHarness(t) - h.git(h.repo, "config", "core.logAllRefUpdates", "always") - workDir, row := h.prepare(317) - h.write(workDir, "c.txt", "c\n") - h.git(workDir, "add", "c.txt") - h.git(workDir, "commit", "-q", "-m", "c") - sha := h.git(workDir, "rev-parse", "HEAD") - // A per-worktree ref pointed at it and was moved away; only its reflog - // reaches it now. - h.git(workDir, "update-ref", "refs/worktree/keep", sha) - h.git(workDir, "update-ref", "refs/worktree/keep", row.BaseCommit) - h.git(workDir, "reset", "-q", "--hard", row.BaseCommit) - h.git(workDir, "reflog", "expire", "--expire=now", "HEAD") - h.git(h.repo, "reflog", "expire", "--expire=now", "refs/heads/"+row.Branch) - - after := h.discard(workDir) - assert.Equal(t, WorktreeRetained, after.State) - assert.Equal(t, RetainedUnpushed, after.RetainedReason) - assert.NoError(t, exec.CommandContext(context.Background(), "git", "-C", h.repo, "cat-file", "-e", sha+"^{commit}").Run()) -} - -// A repository that keeps no reflogs tells the rule nothing about what a -// worktree reached: what cannot be read is not judged clean. -func TestAWorktreeWithNoReflogIsNotJudgedClean(t *testing.T) { - h := newWorktreeHarness(t) - h.git(h.repo, "config", "core.logAllRefUpdates", "false") - workDir, row := h.prepare(308) - // The commit only the reflog would reach, in a repository that keeps - // none: the worktree and its branch are all that hold it. - h.git(workDir, "checkout", "-q", "--detach") - h.write(workDir, "c.txt", "c\n") - h.git(workDir, "add", "c.txt") - h.git(workDir, "commit", "-q", "-m", "c") - sha := h.git(workDir, "rev-parse", "HEAD") - h.git(workDir, "checkout", "-q", row.Branch) - - after := h.discard(workDir) - assert.Equal(t, WorktreeRetained, after.State) - assert.Equal(t, RetainedUnverified, after.RetainedReason) - assert.NoError(t, exec.CommandContext(context.Background(), "git", "-C", h.repo, "cat-file", "-e", sha+"^{commit}").Run(), "the commit is still there") - - // And the operator can still get rid of it: a force is a decision, not a - // judgment, so a row nothing can read is not a row nothing can clear. In - // a repository that keeps no reflogs there is nothing left pointing at - // that commit for anyone — the connector included — to keep. - results, err := h.wt.Prune(context.Background(), []string{after.Path}) - require.NoError(t, err) - require.Len(t, results, 1) - assert.Equal(t, PruneForced, results[0].Action) - assert.Equal(t, WorktreeRemoved, h.row(workDir).State) - assert.False(t, exists(after.Path)) -} - -// A commit only the record's ORIG_HEAD reaches goes with the record: it is -// judged like any other commit the worktree reaches. -func TestACommitOnlyOrigHeadReachesIsKept(t *testing.T) { - h := newWorktreeHarness(t) - workDir, row := h.prepare(309) - h.write(workDir, "c.txt", "c\n") - h.git(workDir, "add", "c.txt") - h.git(workDir, "commit", "-q", "-m", "c") - sha := h.git(workDir, "rev-parse", "HEAD") - h.git(workDir, "reset", "-q", "--hard", row.BaseCommit) - h.git(workDir, "reflog", "expire", "--expire=now", "--all") - require.Equal(t, sha, h.git(workDir, "rev-parse", "ORIG_HEAD")) - - after := h.discard(workDir) - assert.Equal(t, WorktreeRetained, after.State) - assert.Equal(t, RetainedUnpushed, after.RetainedReason) - assert.NoError(t, exec.CommandContext(context.Background(), "git", "-C", h.repo, "cat-file", "-e", sha+"^{commit}").Run(), "the commit is still there") -} - -// The judgment leans on every ref that holds a commit the worktree reaches, -// not only the one holding its branch tip: a holder that moves between the -// check and the removal keeps the worktree, commit and all. -func TestAHolderOffTheBranchTipMustNotMoveEither(t *testing.T) { - h := newWorktreeHarness(t) - workDir, row := h.prepare(305) - h.write(workDir, "off.txt", "off\n") - h.git(workDir, "add", "off.txt") - h.git(workDir, "commit", "-q", "-m", "off the tip") - off := h.git(workDir, "rev-parse", "HEAD") - // Another branch holds that commit, and the task branch is rolled back to - // its base: the commit is reachable from the worktree's reflogs, and what - // holds it is not the branch tip's holder. - h.git(h.repo, "branch", "keeper", off) - h.git(workDir, "reset", "-q", "--hard", row.BaseCommit) - // Just before the removal's transaction, keeper is moved off it. - h.wt = h.worktrees(fakeGit(t, `case "$*" in *"update-ref --stdin"*) "$REAL" -C "`+h.repo+`" update-ref refs/heads/keeper `+row.BaseCommit+`;; esac`)) - - after := h.discard(workDir) - assert.Equal(t, WorktreeRetained, after.State, "the judgment no longer stands") - assert.True(t, exists(workDir), "the worktree is still there") - assert.Equal(t, off, h.git(workDir, "rev-parse", "HEAD@{1}"), "and the commit with it") - assert.Empty(t, h.git(h.repo, "for-each-ref", "--format=%(refname)", RetainedRefPrefix), - "a removal that did not happen leaves no ref of the connector's own behind") -} - -// The commit a worktree was made from is judged like any other: the route's -// branch usually holds it, but a route reset since is not evidence that it -// does, and the task branch is then the only thing reaching it. -func TestTheBaseCommitIsNotAssumedHeld(t *testing.T) { - h := newWorktreeHarness(t) - // A commit on the route's branch that was never pushed, and the worktree - // made from it. - h.write(h.repo, "app/base.txt", "base\n") - h.git(h.repo, "add", ".") - h.git(h.repo, "commit", "-q", "-m", "base") - base := h.git(h.repo, "rev-parse", "HEAD") - workDir, row := h.prepare(307) - require.Equal(t, base, row.BaseCommit) - // The route's branch is reset away: nothing but the task branch reaches - // that commit any more. - h.git(h.repo, "reset", "-q", "--hard", "HEAD~1") - - after := h.discard(workDir) - assert.Equal(t, WorktreeRetained, after.State) - assert.Equal(t, RetainedUnpushed, after.RetainedReason) - assert.True(t, h.branchExists(row.Branch), "the branch reaching the commit is kept") - assert.Equal(t, base, h.git(h.repo, "rev-parse", "refs/heads/"+row.Branch)) -} - -// The commits are held before the branch that reaches them goes, not after: -// nothing between the two can leave a commit with no ref at all. -func TestTheCommitsAreHeldBeforeTheBranchGoes(t *testing.T) { - h := newWorktreeHarness(t) - workDir, row := h.prepare(314) - h.git(workDir, "checkout", "-q", "--detach") - h.write(workDir, "c.txt", "c\n") - h.git(workDir, "add", "c.txt") - h.git(workDir, "commit", "-q", "-m", "c") - sha := h.git(workDir, "rev-parse", "HEAD") - h.git(workDir, "checkout", "-q", row.Branch) - h.git(h.repo, "branch", "keeper", sha) - // The moment the branch's transaction runs, every commit must already be - // held by a ref of the connector's own. - // Only the first transaction is looked at: that is the branch's. - held := filepath.Join(t.TempDir(), "held") - h.wt = h.worktrees(fakeGit(t, `case "$*" in *"update-ref --stdin"*) [ -f `+held+` ] || "$REAL" -C "`+h.repo+`" for-each-ref --contains `+sha+` --format='%(refname)' refs/basecamp-connect/removing/ > `+held+`;; esac`)) - - after := h.discard(workDir) - require.Equal(t, WorktreeRemoved, after.State) - data, err := os.ReadFile(held) - require.NoError(t, err) - assert.Contains(t, string(data), RemovingRefPrefix, "the commit was held when the branch's transaction ran") -} - -// A worktree whose directory something outside the connector removed: the -// row says so, git's record and the task branch are left exactly as they are, -// and nothing about what they reach is judged. An operator who names it is -// told what goes and it goes. -func TestADirectoryRemovedFromUnderTheConnectorIsOrphanedNotJudged(t *testing.T) { - h := newWorktreeHarness(t) - workDir, row := h.prepare(315) - h.write(workDir, "c.txt", "c\n") - h.git(workDir, "add", "c.txt") - h.git(workDir, "commit", "-q", "-m", "c") - sha := h.git(workDir, "rev-parse", "HEAD") - h.git(workDir, "reset", "-q", "--hard", row.BaseCommit) - require.Equal(t, sha, h.git(workDir, "rev-parse", "ORIG_HEAD")) - require.Equal(t, WorktreeRetained, h.finish(workDir).State) - require.NoError(t, os.RemoveAll(row.Path)) - - results, err := h.wt.Prune(context.Background(), nil) - require.NoError(t, err) - require.Len(t, results, 1) - assert.Equal(t, PruneKept, results[0].Action) - assert.Equal(t, RetainedOrphaned, results[0].Reason) - assert.True(t, h.branchExists(row.Branch), "the branch is left alone") - assert.True(t, exists(row.AdminDir), "and so is git's record of the worktree") - assert.NoError(t, exec.CommandContext(context.Background(), "git", "-C", h.repo, "cat-file", "-e", sha+"^{commit}").Run()) - - // The explicit discard, naming it: the branch goes, the record is left - // for `git worktree prune`, and the row is closed. - forced, err := h.wt.Prune(context.Background(), []string{row.Path}) - require.NoError(t, err) - require.Len(t, forced, 1) - assert.Equal(t, PruneForced, forced[0].Action) - assert.False(t, h.branchExists(row.Branch)) - assert.Equal(t, WorktreeRemoved, h.row(workDir).State) -} - -// The connector deletes no ref of its own accord either: a task whose -// directory is gone keeps its branch, and with it the commits only that -// branch's reflog reaches. -func TestATaskEndDeletesNoBranchOfItsOwnAccord(t *testing.T) { - h := newWorktreeHarness(t) - workDir, row := h.prepare(312) - h.write(workDir, "c.txt", "c\n") - h.git(workDir, "add", "c.txt") - h.git(workDir, "commit", "-q", "-m", "c") - sha := h.git(workDir, "rev-parse", "HEAD") - // The branch is back at its base, and only its own reflog reaches that - // commit; the directory is gone when the task ends. - h.git(h.repo, "update-ref", "refs/heads/"+row.Branch, row.BaseCommit) - require.NoError(t, os.RemoveAll(row.Path)) - - after := h.finish(workDir) - assert.Equal(t, WorktreeRetained, after.State) - assert.True(t, h.branchExists(row.Branch), "the branch is the operator's to lose, not the connector's") - assert.NoError(t, exec.CommandContext(context.Background(), "git", "-C", h.repo, "cat-file", "-e", sha+"^{commit}").Run()) - assert.Equal(t, sha, h.git(h.repo, "rev-parse", row.Branch+"@{1}"), "its reflog still reaches the commit") -} - -// A prune of the same worktree is judged on what holds its commits, not on -// what a removal that stopped halfway left behind. -func TestAnAbandonedRemovalsRefsDoNotPassTheNextJudgment(t *testing.T) { - h := newWorktreeHarness(t) - workDir, row := h.prepare(313) - h.write(workDir, "c.txt", "c\n") - h.git(workDir, "add", "c.txt") - h.git(workDir, "commit", "-q", "-m", "c") - sha := h.git(workDir, "rev-parse", "HEAD") - require.Equal(t, WorktreeRetained, h.finish(workDir).State) - // A removal that got as far as holding the commits and then stopped: the - // refs it made are still there. - held, err := h.wt.anchor(context.Background(), row, []string{sha}) - require.NoError(t, err) - require.Len(t, held, 1) - require.Equal(t, sha, h.git(h.repo, "rev-parse", held[0])) - - results, err := h.wt.Prune(context.Background(), nil) - require.NoError(t, err) - require.Len(t, results, 1) - assert.Equal(t, PruneKept, results[0].Action, "the commit is still unpushed") - assert.Equal(t, RetainedUnpushed, results[0].Reason, "a ref the connector left behind holds nothing for anybody else") - assert.True(t, exists(row.Path)) -} - -func TestABranchWhoseHolderMovedIsNotDeleted(t *testing.T) { - h := newWorktreeHarness(t) - workDir, row := h.prepare(304) - h.write(workDir, "c.txt", "c\n") - h.git(workDir, "add", "c.txt") - h.git(workDir, "commit", "-q", "-m", "c") - h.git(workDir, "push", "-q", "origin", row.Branch) - // Just before the transaction, the only holder, the remote-tracking ref, - // is reset away. - h.wt = h.worktrees(fakeGit(t, `case "$*" in *"update-ref --stdin"*) "$REAL" -C "`+h.repo+`" update-ref refs/remotes/origin/`+row.Branch+` `+row.BaseCommit+`;; esac`)) - after := h.discard(workDir) - // Nothing holds the commit any more: the worktree and its branch stay. - assert.Equal(t, WorktreeRetained, after.State) - assert.True(t, h.branchExists(row.Branch), "the branch holding the commit alone is kept") - assert.True(t, exists(workDir), "the worktree the branch is checked out in is kept") -} - -// Git data of a repository inside the worktree — one a worker made, not a -// submodule of the route's — is work no ref can keep: never removed, and a -// force is refused. -func TestARepositoryTheWorkerMadeIsNeverRemoved(t *testing.T) { - h := newWorktreeHarness(t) - ctx := context.Background() - workDir, _ := h.prepare(400) - nested := filepath.Join(workDir, "vendor", "lib") - require.NoError(t, os.MkdirAll(nested, 0o700)) - h.git(nested, "init", "-q", "-b", "main") - h.write(nested, "lib.txt", "lib\n") - h.git(nested, "add", ".") - h.git(nested, "commit", "-q", "-m", "only copy") - commit := h.git(nested, "rev-parse", "HEAD") - - row := h.discard(workDir) - require.Equal(t, RetainedDirty, row.RetainedReason) - results, err := h.wt.Prune(ctx, []string{row.Path}) - require.NoError(t, err) - require.Len(t, results, 1) - assert.Equal(t, PruneKept, results[0].Action) - assert.True(t, results[0].ForceRefused) - assert.DirExists(t, filepath.Join(nested, ".git")) - assert.NoError(t, exec.CommandContext(ctx, "git", "-C", nested, "cat-file", "-e", commit+"^{commit}").Run()) -} - -// A crash between `worktree add --no-checkout` and the checkout leaves a -// directory that was never checked out: nothing in it to lose. -func TestAWorktreeThatWasNeverCheckedOutIsKeptThenPruned(t *testing.T) { - h := newWorktreeHarness(t) - ctx := context.Background() - base := h.git(h.repo, "rev-parse", "HEAD") - path := filepath.Join(h.root, "repo", "401-abcdef") - record := Worktree{ - Path: path, WorkDir: filepath.Join(path, "app"), Route: filepath.Join(h.repo, "app"), Repository: h.repo, - Branch: BranchPrefix + "401-abcdef", BaseCommit: base, OriginatingEventID: 401, State: WorktreeCreating, - } - id, err := h.ledger.BeginWorktree(ctx, record) - require.NoError(t, err) - require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) - h.git(h.repo, "update-ref", "refs/heads/"+record.Branch, base, "") - require.NoError(t, h.ledger.WorktreeBranchCreated(ctx, id)) - h.git(h.repo, "worktree", "add", "--no-checkout", "-q", path, record.Branch) - require.FileExists(t, filepath.Join(path, ".git")) - - // Recovery keeps it, as it keeps everything on disk. - require.NoError(t, h.wt.Recover(ctx)) - rows, err := h.ledger.Worktrees(ctx) - require.NoError(t, err) - require.Len(t, rows, 1) - assert.Equal(t, WorktreeRetained, rows[0].State) - assert.True(t, exists(path)) - - // The operator's prune reads it for what it is: a checkout that never - // happened, holding nothing. - results, err := h.wt.Prune(ctx, nil) - require.NoError(t, err) - require.Len(t, results, 1) - assert.Equal(t, PruneRemoved, results[0].Action) - assert.False(t, exists(path)) -} - -// A frozen name already taken keeps the worktree, untouched. -func TestAFrozenNameAlreadyTakenKeepsTheWorktree(t *testing.T) { - h := newWorktreeHarness(t) - workDir, row := h.prepare(402) - require.NoError(t, os.Mkdir(frozenName(row.Path), 0o700)) - - after := h.discard(workDir) - assert.Equal(t, WorktreeRetained, after.State) - assert.Equal(t, RetainedUnverified, after.RetainedReason) - assert.DirExists(t, workDir) - assert.NoFileExists(t, filepath.Join(row.AdminDir, "locked"), "no lock is left on the record") -} - -// A row without a stored record, in a repository that writes relative worktree -// paths, is still proven and removed. -func TestALegacyRowWithRelativePathsIsRemoved(t *testing.T) { - h := newWorktreeHarness(t) - ctx := context.Background() - h.git(h.repo, "config", "worktree.useRelativePaths", "true") - workDir, row := h.prepare(403) - _, err := h.ledger.db.ExecContext(ctx, `UPDATE worktrees SET admin_dir = '' WHERE id = ?`, row.ID) - require.NoError(t, err) - - after := h.discard(workDir) - assert.Equal(t, WorktreeRemoved, after.State) - assert.False(t, exists(row.Path)) - assert.NoDirExists(t, row.AdminDir) -} - -// A conflicted merge leaves AUTO_MERGE naming the tree ort merged to, which -// is an object no ref can be asked to contain. A force must still go through: -// a tree names no history, so there is nothing there to keep. -func TestAForcedPruneIsNotStoppedByAPseudoRefNamingATree(t *testing.T) { - h := newWorktreeHarness(t) - workDir, row := h.prepare(325) - h.git(h.repo, "branch", "theirs") - h.git(h.repo, "checkout", "-q", "theirs") - h.write(h.repo, "app/README", "theirs\n") - h.git(h.repo, "commit", "-q", "-am", "theirs") - h.git(h.repo, "checkout", "-q", "main") - h.write(workDir, "README", "ours\n") - h.git(workDir, "commit", "-q", "-am", "ours") - h.gitConflicting(workDir, "merge", "theirs") - autoMerge, err := os.ReadFile(filepath.Join(row.AdminDir, "AUTO_MERGE")) - require.NoError(t, err, "the conflicted merge left AUTO_MERGE in the record") - require.Equal(t, "tree", h.git(h.repo, "cat-file", "-t", strings.TrimSpace(string(autoMerge)))) - require.Equal(t, WorktreeRetained, h.finish(workDir).State) - - results, err := h.wt.Prune(context.Background(), []string{row.Path}) - require.NoError(t, err) - require.Len(t, results, 1) - assert.Equal(t, PruneForced, results[0].Action, "reason: %s", results[0].Reason) - assert.False(t, exists(row.Path)) -} - -// A HEAD that names no commit — a worker's `checkout --orphan` — reaches -// nothing through HEAD. That is not a git the connector could not run, and a -// force is not refused over it: the worktree would otherwise be one no -// command could ever remove. -func TestAWorktreeWhoseHeadNamesNoCommitIsStillForced(t *testing.T) { - h := newWorktreeHarness(t) - workDir, row := h.prepare(326) - h.git(workDir, "checkout", "-q", "--detach") - h.write(workDir, "c.txt", "c\n") - h.git(workDir, "add", "c.txt") - h.git(workDir, "commit", "-q", "-m", "detached") - commit := h.git(workDir, "rev-parse", "HEAD") - h.git(workDir, "checkout", "-q", "--orphan", "fresh") - require.Equal(t, WorktreeRetained, h.finish(workDir).State) - - results, err := h.wt.Prune(context.Background(), []string{row.Path}) - require.NoError(t, err) - require.Len(t, results, 1) - assert.Equal(t, PruneForced, results[0].Action, "reason: %s", results[0].Reason) - assert.False(t, exists(row.Path)) - assert.Contains(t, h.git(h.repo, "for-each-ref", "--format=%(objectname)", RetainedRefPrefix), commit, - "what HEAD stood at before the orphan is kept: only its reflog still reaches it") -} - -// A per-worktree ref names whatever a worker put under it, and the judgment -// is about the commit that reaches: an annotated tag is history to keep, -// which is what asking git what contains it used to say. -func TestAPerWorktreeRefAtAnAnnotatedTagKeepsItsCommit(t *testing.T) { - h := newWorktreeHarness(t) - workDir, row := h.prepare(327) - // A commit nothing else reaches: its branch and its tag ref are gone, - // and the tag object is left only under the worktree's own ref. - h.git(h.repo, "checkout", "-q", "-b", "temp") - h.write(h.repo, "app/t.txt", "t\n") - h.git(h.repo, "add", "app/t.txt") - h.git(h.repo, "commit", "-q", "-m", "tagged") - tagged := h.git(h.repo, "rev-parse", "HEAD") - h.git(h.repo, "tag", "-a", "-m", "kept", "kept") - tag := h.git(h.repo, "rev-parse", "kept") - h.git(h.repo, "checkout", "-q", "main") - h.git(h.repo, "branch", "-q", "-D", "temp") - h.git(h.repo, "tag", "-d", "kept") - h.git(workDir, "update-ref", "refs/worktree/kept", tag) - require.Equal(t, WorktreeRetained, h.finish(workDir).State) - - results, err := h.wt.Prune(context.Background(), []string{row.Path}) - require.NoError(t, err) - require.Len(t, results, 1) - assert.Equal(t, PruneForced, results[0].Action, "reason: %s", results[0].Reason) - assert.Contains(t, h.git(h.repo, "for-each-ref", "--format=%(objectname)", RetainedRefPrefix), tagged, - "the tag's commit is kept, not dropped with the tag") -} - -// A planner answers where a task would work and what its checkout would -// hold, and makes nothing: doctor has to ask the question a dispatch -// answers, and must not create a worktree, a root or a ledger row to do it. -func TestPlanSaysWhereATaskWouldWorkWithoutMakingAnything(t *testing.T) { - h := newWorktreeHarness(t) - ctx := context.Background() - route := filepath.Join(h.repo, "app") - - planner, err := PlanWorktrees(WorktreesOptions{Root: h.root, Lookup: h.lookup}) - require.NoError(t, err) - plan, err := planner.Plan(ctx, route, "doctor") - require.NoError(t, err) - assert.Equal(t, h.repo, plan.Repository) - assert.Equal(t, "app", plan.Rel) - assert.Equal(t, filepath.Join(plan.Worktree, "app"), plan.Dir) - assert.NotEqual(t, route, plan.Dir, "a task with worktrees on does not work in the route") - _, err = os.Stat(plan.Worktree) - assert.ErrorIs(t, err, os.ErrNotExist, "planning creates no worktree") - _, err = os.Stat(h.root) - assert.ErrorIs(t, err, os.ErrNotExist, "and no root to hold one") - - // Everything that would make, record or remove one refuses. - _, err = planner.Prepare(ctx, route, 1) - assert.ErrorIs(t, err, ErrPlanOnly) - assert.ErrorIs(t, planner.Finish(ctx, route, plan.Dir), ErrPlanOnly) - assert.ErrorIs(t, planner.Recover(ctx), ErrPlanOnly) - _, err = planner.Prune(ctx, nil) - assert.ErrorIs(t, err, ErrPlanOnly) - - // The plan is the directory Prepare actually gives a task: same route, - // same name, same path. - workDir, err := h.wt.Prepare(ctx, route, 77) - require.NoError(t, err) - name := filepath.Base(filepath.Dir(workDir)) - made, err := planner.Plan(ctx, route, name) - require.NoError(t, err) - assert.Equal(t, workDir, made.Dir, "doctor plans the directory a dispatch uses, not one like it") -} - -// A planned worktree's files are the repository's at the commit it would be -// made from: a file only the route has is in no worktree, and a file the -// commit has is in every one of them, whatever the checkout says now. -func TestPlannedWorkDirReadsTheCommitTheWorktreeWouldBeMadeFrom(t *testing.T) { - h := newWorktreeHarness(t) - ctx := context.Background() - route := filepath.Join(h.repo, "app") - h.write(h.repo, "app/tracked.toml", "committed\n") - h.git(h.repo, "add", ".") - h.git(h.repo, "commit", "-q", "-m", "tracked") - - planner, err := PlanWorktrees(WorktreesOptions{Root: h.root, Lookup: h.lookup}) - require.NoError(t, err) - plan, err := planner.Plan(ctx, route, "doctor") - require.NoError(t, err) - - got, err := plan.ReadFile(ctx, filepath.Join(plan.Dir, "tracked.toml")) - require.NoError(t, err) - assert.Equal(t, "committed\n", string(got), "a tracked file reads as the commit has it") - - h.write(h.repo, "app/tracked.toml", "edited in the route\n") - got, err = plan.ReadFile(ctx, filepath.Join(plan.Dir, "tracked.toml")) - require.NoError(t, err) - assert.Equal(t, "committed\n", string(got), "the worktree would hold the commit, not the route's edit") - - h.write(h.repo, "app/untracked.toml", "only in the route\n") - _, err = plan.ReadFile(ctx, filepath.Join(plan.Dir, "untracked.toml")) - assert.ErrorIs(t, err, os.ErrNotExist, "an untracked file is in no worktree") - - // Outside the worktree is the machine, read as it is. - outside := filepath.Join(h.home, "outside.toml") - require.NoError(t, os.WriteFile(outside, []byte("on disk\n"), 0o600)) - got, err = plan.ReadFile(ctx, outside) - require.NoError(t, err) - assert.Equal(t, "on disk\n", string(got)) - _, err = plan.ReadFile(ctx, filepath.Join(h.home, "missing.toml")) - assert.ErrorIs(t, err, os.ErrNotExist) -} - -// A planned read models a regular file the repository tracks, and says so -// about everything else. A symbolic link is followed by the kernel at -// dispatch to wherever it points, and git hands back its target instead of -// that file; a submodule's directory the checkout leaves empty. Reading the -// link's text as configuration is how doctor would call a profile ready -// over a layer nothing read, so a layer like that is named, not guessed. -func TestPlannedWorkDirSaysWhichLayersItDoesNotModel(t *testing.T) { - h := newWorktreeHarness(t) - ctx := context.Background() - route := filepath.Join(h.repo, "app") - - h.write(h.repo, "shared.toml", "shared content\n") - require.NoError(t, os.MkdirAll(filepath.Join(h.repo, "app", ".codex"), 0o700)) - require.NoError(t, os.Symlink("../../shared.toml", filepath.Join(h.repo, "app", ".codex", "config.toml"))) - require.NoError(t, os.Symlink(".codex", filepath.Join(h.repo, "app", "linkdir"))) - h.git(h.repo, "add", ".") - h.git(h.repo, "commit", "-q", "-m", "links") - // A submodule, without fetching anything: a gitlink in the tree is what - // a submodule is to the checkout. - h.git(h.repo, "update-index", "--add", "--cacheinfo", "160000,"+h.git(h.repo, "rev-parse", "HEAD")+",app/vendor") - h.git(h.repo, "commit", "-q", "-m", "a submodule") - - planner, err := PlanWorktrees(WorktreesOptions{Root: h.root, Lookup: h.lookup}) - require.NoError(t, err) - plan, err := planner.Plan(ctx, route, "doctor") - require.NoError(t, err) - - for _, tc := range []struct{ name, path, kind string }{ - {"a link where the file is", filepath.Join(plan.Dir, ".codex", "config.toml"), "a symbolic link"}, - {"a link on the way to it", filepath.Join(plan.Dir, "linkdir", "config.toml"), "a symbolic link"}, - {"a submodule on the way to it", filepath.Join(plan.Dir, "vendor", ".codex", "config.toml"), "a submodule"}, - } { - t.Run(tc.name, func(t *testing.T) { - _, err := plan.ReadFile(ctx, tc.path) - require.ErrorIs(t, err, ErrNotModeled) - assert.NotErrorIs(t, err, os.ErrNotExist, "what was not read is not an absence") - var unmodeled *UnmodeledPathError - require.ErrorAs(t, err, &unmodeled) - assert.Equal(t, tc.kind, unmodeled.Kind) - assert.Contains(t, err.Error(), unmodeled.Path, "the layer that was not read is named") - assert.Contains(t, unmodeled.Path, plan.Worktree) - }) - } - - // A regular file the repository tracks is still read as the checkout - // would have it, which is the whole promise it does make. - got, err := plan.ReadFile(ctx, filepath.Join(plan.Dir, "..", "shared.toml")) - require.NoError(t, err) - assert.Equal(t, "shared content\n", string(got)) - - // And a directory is a directory, a file used as one is not. - _, err = plan.ReadFile(ctx, filepath.Join(plan.Dir, ".codex")) - require.Error(t, err) - assert.Contains(t, err.Error(), "is a directory") - _, err = plan.ReadFile(ctx, filepath.Join(plan.Dir, "README", "config.toml")) - require.Error(t, err) - assert.Contains(t, err.Error(), "not a directory") -} - -// A path in the repository is a path, not an option and not a glob: a -// directory named "-app" or "conf[1]" is read as itself. -func TestPlannedWorkDirReadsPathsGitCouldMistakeForSomethingElse(t *testing.T) { - h := newWorktreeHarness(t) - ctx := context.Background() - for _, dir := range []string{"-app", "conf[1]"} { - require.NoError(t, os.MkdirAll(filepath.Join(h.repo, dir, ".codex"), 0o700)) - h.write(h.repo, filepath.Join(dir, ".codex", "config.toml"), "name = \""+dir+"\"\n") - } - h.git(h.repo, "add", ".") - h.git(h.repo, "commit", "-q", "-m", "awkward names") - - planner, err := PlanWorktrees(WorktreesOptions{Root: h.root, Lookup: h.lookup}) - require.NoError(t, err) - for _, dir := range []string{"-app", "conf[1]"} { - plan, err := planner.Plan(ctx, filepath.Join(h.repo, dir), "doctor") - require.NoError(t, err) - got, err := plan.ReadFile(ctx, filepath.Join(plan.Dir, ".codex", "config.toml")) - require.NoError(t, err, "a directory named %q is a directory, not an option or a pattern", dir) - assert.Equal(t, "name = \""+dir+"\"\n", string(got)) - } -} - -// The planner needs no ledger, so it must not say it does. -func TestPlanWorktreesSaysWhatItActuallyNeeds(t *testing.T) { - _, err := PlanWorktrees(WorktreesOptions{Root: "relative/root"}) - require.ErrorIs(t, err, ErrNoRoot) - assert.NotContains(t, err.Error(), "ledger", "the planner takes none, so a refusal must not ask for one") - - _, err = NewWorktrees(WorktreesOptions{Root: "/tmp"}) - require.Error(t, err) - assert.Contains(t, err.Error(), "ledger", "the one that records worktrees does need it") -} - -// A worktree is the commit's tree written out, so a route the tree does not -// have is a working directory that would not be there: git writes no -// directory for one that is untracked or ignored, and nothing could be -// started in it. It is refused as the route it is, not waited on. -func TestPlanRefusesARouteTheCheckoutWouldNotHold(t *testing.T) { - h := newWorktreeHarness(t) - ctx := context.Background() - planner, err := PlanWorktrees(WorktreesOptions{Root: h.root, Lookup: h.lookup}) - require.NoError(t, err) - - // The tracked route, and the repository itself, are what a checkout has. - for _, route := range []string{filepath.Join(h.repo, "app"), h.repo} { - plan, err := planner.Plan(ctx, route, "doctor") - require.NoError(t, err, "a directory the repository tracks is in every worktree of it") - assert.NotEmpty(t, plan.Dir) - } - - // A directory that is only on disk is in no worktree. - untracked := filepath.Join(h.repo, "scratch") - require.NoError(t, os.MkdirAll(untracked, 0o700)) - _, err = planner.Plan(ctx, untracked, "doctor") - require.ErrorIs(t, err, ErrRouteUnusable) - assert.Contains(t, err.Error(), "no worktree of it would hold the route") - - // One that is ignored is untracked with a reason, and no different. - h.write(h.repo, ".gitignore", "ignored/\n") - h.git(h.repo, "add", ".") - h.git(h.repo, "commit", "-q", "-m", "ignore something") - ignored := filepath.Join(h.repo, "ignored") - require.NoError(t, os.MkdirAll(ignored, 0o700)) - _, err = planner.Plan(ctx, ignored, "doctor") - require.ErrorIs(t, err, ErrRouteUnusable) - - // And a route the commit has as a file — a directory made on disk over - // a file the repository tracks — is not a directory to run in. - h.write(h.repo, "conflict", "a file at HEAD\n") - h.git(h.repo, "add", ".") - h.git(h.repo, "commit", "-q", "-m", "a file where a directory will be") - require.NoError(t, os.Remove(filepath.Join(h.repo, "conflict"))) - require.NoError(t, os.MkdirAll(filepath.Join(h.repo, "conflict"), 0o700)) - _, err = planner.Plan(ctx, filepath.Join(h.repo, "conflict"), "doctor") - require.ErrorIs(t, err, ErrRouteUnusable) - assert.Contains(t, err.Error(), "not a directory a session could run in") - - // A submodule's directory the checkout does make, empty; a symbolic - // link it follows at dispatch. Neither is proved unusable, so neither - // is refused here. - h.git(h.repo, "update-index", "--add", "--cacheinfo", "160000,"+h.git(h.repo, "rev-parse", "HEAD")+",vendor") - require.NoError(t, os.Symlink("app", filepath.Join(h.repo, "applink"))) - h.git(h.repo, "add", "applink") - h.git(h.repo, "commit", "-q", "-m", "a submodule and a link") - require.NoError(t, os.MkdirAll(filepath.Join(h.repo, "vendor"), 0o700)) - for _, route := range []string{filepath.Join(h.repo, "vendor"), filepath.Join(h.repo, "applink")} { - _, err = planner.Plan(ctx, route, "doctor") - assert.NotErrorIs(t, err, ErrRouteUnusable, "%s is not proved unusable", route) - } -} - -// A planner is only a planner: it has no ledger, so everything the ledger -// answers refuses rather than reaching for one, and it is not asked for at -// all where there are no worktrees to plan. -func TestAPlannerRefusesEverythingItCannotAnswer(t *testing.T) { - h := newWorktreeHarness(t) - ctx := context.Background() - planner, err := PlanWorktrees(WorktreesOptions{Root: h.root, Lookup: h.lookup}) - require.NoError(t, err) - - _, err = planner.Retained(ctx) - assert.ErrorIs(t, err, ErrPlanOnly, "a planner has no ledger to list worktrees from") - _, err = planner.Prepare(ctx, filepath.Join(h.repo, "app"), 1) - assert.ErrorIs(t, err, ErrPlanOnly) - assert.ErrorIs(t, planner.Finish(ctx, "", ""), ErrPlanOnly) - assert.ErrorIs(t, planner.Recover(ctx), ErrPlanOnly) - _, err = planner.Prune(ctx, nil) - assert.ErrorIs(t, err, ErrPlanOnly) - - // With worktrees off, a task works in its route and there is nothing to - // plan: a planner that answered anyway would answer for a dispatch that - // would not happen. - _, err = PlanWorktrees(WorktreesOptions{Root: h.root, Lookup: h.lookup, Off: true}) - assert.ErrorIs(t, err, ErrPlanNothing) -} diff --git a/skill-evals/cases/basecamp-connect/not-ready-agent-reads.yml b/skill-evals/cases/basecamp-connect/not-ready-agent-reads.yml index 3a2a7c063..16f554bb2 100644 --- a/skill-evals/cases/basecamp-connect/not-ready-agent-reads.yml +++ b/skill-evals/cases/basecamp-connect/not-ready-agent-reads.yml @@ -11,7 +11,7 @@ mocks: - match: '^me .*jorge' output: '{"ok":true,"data":{"identity":{"id":6001,"first_name":"Jorge","last_name":"M","email_address":"jorge@example.com"},"accounts":[{"id":999,"name":"Acme","current":true}],"person":{"id":1001,"name":"Jorge M"}}}' - match: 'connect show' - output: '{"ok":true,"data":{"path":"/home/me/.config/basecamp/connect/helper/connect.json","version":1,"profile":"helper","account_id":"999","agent":{"person_id":4001,"kind":"agent"},"trust":{"mode":"operator","operator_id":1001},"projects":{"111":{"path":"/home/me/Work/marketing"}},"driver":"spawn","concurrency":2,"deadline":"45m0s","worktrees":false},"summary":"Profile \"helper\": trust operator, 1 routed project(s)"}' + output: '{"ok":true,"data":{"path":"/home/me/.config/basecamp/connect/helper/connect.json","version":1,"profile":"helper","account_id":"999","agent":{"person_id":4001,"kind":"agent"},"trust":{"mode":"operator","operator_id":1001},"projects":{"111":{"path":"/home/me/Work/marketing"}},"driver":"spawn","concurrency":2,"deadline":"45m0s"},"summary":"Profile \"helper\": trust operator, 1 routed project(s)"}' - match: 'auth status' output: '{"ok":true,"data":{"profile":"helper","authenticated":true,"oauth_type":"agent","account_id":"999","storage":"keyring"}}' - match: 'profile list' diff --git a/skill-evals/cases/basecamp-connect/route-quoting.yml b/skill-evals/cases/basecamp-connect/route-quoting.yml index 282e20954..df7033305 100644 --- a/skill-evals/cases/basecamp-connect/route-quoting.yml +++ b/skill-evals/cases/basecamp-connect/route-quoting.yml @@ -12,7 +12,7 @@ mocks: - match: '^me .*jorge' output: '{"ok":true,"data":{"identity":{"id":6001,"first_name":"Jorge","last_name":"M","email_address":"jorge@example.com"},"accounts":[{"id":999,"name":"Acme","current":true}],"person":{"id":1001,"name":"Jorge M"}}}' - match: 'connect show' - output: '{"ok":true,"data":{"path":"/home/me/.config/basecamp/connect/helper/connect.json","version":1,"profile":"helper","account_id":"999","agent":{"person_id":4001,"kind":"agent"},"trust":{"mode":"operator","operator_id":1001},"projects":{"111":{"path":"/home/me/Work/marketing"}},"driver":"spawn","concurrency":2,"deadline":"45m0s","worktrees":false},"summary":"Profile \"helper\": trust operator, 1 routed project(s)"}' + output: '{"ok":true,"data":{"path":"/home/me/.config/basecamp/connect/helper/connect.json","version":1,"profile":"helper","account_id":"999","agent":{"person_id":4001,"kind":"agent"},"trust":{"mode":"operator","operator_id":1001},"projects":{"111":{"path":"/home/me/Work/marketing"}},"driver":"spawn","concurrency":2,"deadline":"45m0s"},"summary":"Profile \"helper\": trust operator, 1 routed project(s)"}' - match: 'auth status' output: '{"ok":true,"data":{"profile":"helper","authenticated":true,"oauth_type":"agent","account_id":"999","storage":"keyring"}}' - match: 'projects list' diff --git a/skills/basecamp-connect/SKILL.md b/skills/basecamp-connect/SKILL.md index 23d20308d..23fc326fc 100644 --- a/skills/basecamp-connect/SKILL.md +++ b/skills/basecamp-connect/SKILL.md @@ -148,8 +148,7 @@ widens trust. "driver": "spawn", "worker": "claude", "concurrency": 2, - "deadline": "45m0s", - "worktrees": false + "deadline": "45m0s" } ``` @@ -168,7 +167,6 @@ widens trust. | `worker` | Which coding agent a spawn worker is: `claude` (default) or `codex` | `--worker` | | `concurrency` | Workers at once, 1 to 32 (default 2) | `--concurrency` | | `deadline` | Time limit per task, 1m to 24h (default 45m) | `--deadline 90m` | -| `worktrees` | Each task gets its own git worktree of the routed directory, kept when the task ends and removed only by `connect worktrees prune` | `--worktrees`, `--worktrees=false` | **Never edit connect.json by hand.** It is the trust anchor: setup verifies every person and route before writing it, writes it owner-only, and parses it @@ -284,8 +282,7 @@ pass `--allow ` for each. - Show the names, let the person choose, and map each choice to its numeric `id` yourself. When a name matches more than one project, ask which. - For each project ask which local directory its work runs in. Check the - directory exists. If worktrees are wanted, check it is a git repository - (`git -C '' rev-parse --show-toplevel`). + directory exists. - Offer `--watch-completions` only when the person wants the agent to act on every completed to-do or card in a project without being assigned. Offer `--class` only when they want projects labelled (for example `internal`; see @@ -302,7 +299,7 @@ basecamp connect setup -P '' --operator-profile '' \ --route '=' --route '=' --json ``` -adding `--trust`, `--allow`, `--watch-completions`, `--class` or `--worktrees` +adding `--trust`, `--allow`, `--watch-completions` or `--class` as chosen, and on the bot-user path `--expect-identity `. **6. Read the result** (next section) and tell the person what it means. When it @@ -323,7 +320,7 @@ project names up the same way as on first setup, and quote values by the Shell q | Trust only the operator, or project members | `--trust operator` / `--trust project` (leaving allowlist mode drops the list) | | Trust specific people | `--allow ` for each; the list you pass **replaces** the old one, so pass everyone who stays | | Change the operator | `--operator-profile ''` | -| Change workers | `--driver`, `--worker claude` / `--worker codex`, `--concurrency`, `--deadline`, `--worktrees` / `--worktrees=false` | +| Change workers | `--driver`, `--worker claude` / `--worker codex`, `--concurrency`, `--deadline` | | Replace the agent's credential (only with the person's consent: it rotates the secret) | `basecamp auth agent connect -P ''`, then setup with no flags to re-check | A class or watch setting needs the project routed first, in the same run or an diff --git a/skills/basecamp/SKILL.md b/skills/basecamp/SKILL.md index d4d41a281..04bb5c0ef 100644 --- a/skills/basecamp/SKILL.md +++ b/skills/basecamp/SKILL.md @@ -1456,9 +1456,7 @@ basecamp auth agent connect -P agent # Connect this computer to a basecamp connect setup -P agent --operator-profile --route = # Set up a local agent connector on a connected profile (run `auth agent connect` first): verifies trust, checks token, identity, scope, ticket mint and project reads, then writes connect.json basecamp connect -P agent # Run the connector in the foreground: hear the agent's events, admit what a trusted person asks, and hand the work to a local coding agent that replies as the agent basecamp connect -P agent --project --shadow # Narrow it to one project, and watch without acting: an isolated state directory, nothing dispatched and nothing posted -basecamp connect setup -P agent --worker codex --worktrees # Run workers with Codex instead of Claude Code, and give each task its own git worktree -basecamp connect worktrees list -P agent --json # The worktrees the connector kept: every task's, with its size on disk, git's record of it, and why it is kept (finished, dirty, unpushed, locked, moved, unverified, orphaned) -basecamp connect worktrees prune -P agent # The only thing that removes a worktree: removes the kept ones that hold no work; --force removes one that does (on disk: every commit it reaches is kept under refs/basecamp-connect/retained/; orphaned: the task branch goes and the commit it stood at is reported) +basecamp connect setup -P agent --worker codex # Run workers with Codex instead of Claude Code ``` `basecamp connect` runs until it is stopped: it is not a command to call for an @@ -1471,15 +1469,10 @@ there. It refuses a second connector for the same agent, and takes `--project` (repeatable) to hear and dispatch only those projects. Run it under a supervisor rather than from a session you will close. -With worktrees on, a task's worktree is kept when the task ends — the connector -removes none of its own accord — and listed by `connect worktrees list` with its -size. Removing them is the operator's call: `connect worktrees prune` removes -those that hold no work, and never pass `--force` for a path the operator did not -name. A worktree whose directory something else removed is reported as -`orphaned`: the connector leaves git's record of it and the task branch exactly -as they are, and only a force on its path deletes the branch. A Codex worker cannot commit (its sandbox cannot write the -worktree's git data), so with Codex every task that edits files leaves a kept -worktree. +A task runs in the directory its project is routed to, and the connector +prepares nothing: it makes no directory, no clone and no branch. Work that +needs one of its own is the agent's to make, from its own skills and +instructions. **Before running ANY of the logins above, check `oauth_type`.** `basecamp auth status --json` reports it, and `agent` means the profile is a Basecamp agent: a From 6c3d98f7be1c9d102506cb1ad5cc3c38a8830c6e Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 18 Sep 2026 16:57:24 +0200 Subject: [PATCH 2/4] Take worktrees out of the one-owner rule's prose and the setup test The rule is about a task's working directory; it no longer has a worktree to also be about. The setup test's repository route existed only so a rerun could turn --worktrees on. --- internal/commands/connect_setup_test.go | 16 ++-------------- .../connector/driver/drivertest/drivertest.go | 8 ++++---- internal/connector/driver/worker.go | 14 +++++++------- 3 files changed, 13 insertions(+), 25 deletions(-) diff --git a/internal/commands/connect_setup_test.go b/internal/commands/connect_setup_test.go index 0d38828e8..26017b9ec 100644 --- a/internal/commands/connect_setup_test.go +++ b/internal/commands/connect_setup_test.go @@ -294,16 +294,6 @@ func routeArg(t *testing.T) string { return fmt.Sprintf("--route=%d=%s", setupProject, t.TempDir()) } -// repositoryRouteArg routes the project to a directory in a repository, for -// a setup that turns worktrees on: every task then branches from the route, -// and setup refuses a route no worktree could be made in. -func repositoryRouteArg(t *testing.T) string { - t.Helper() - dir := t.TempDir() - require.NoError(t, os.MkdirAll(filepath.Join(dir, ".git"), 0o700)) - return fmt.Sprintf("--route=%d=%s", setupProject, dir) -} - // firstSetup runs a successful first setup of the agent profile. func firstSetup(t *testing.T, s *connectSetupServer) { t.Helper() @@ -680,10 +670,8 @@ func TestConnectSetupOnTheBotUserPath(t *testing.T) { require.NoError(t, err) assert.Equal(t, setup.Agent{PersonID: setupBotPerson, Kind: setup.KindBotUser, IdentityID: setupBotIdentity}, f.Agent) - // The pinned identity is remembered: a rerun need not restate it. The - // route is restated because this rerun turns worktrees on, and a route - // with no repository takes no worktree. - out, err = runConnectSetupCmd(t, newConnectSetupApp(t, s, "bot"), "--worktrees", repositoryRouteArg(t)) + // The pinned identity is remembered: a rerun need not restate it. + out, err = runConnectSetupCmd(t, newConnectSetupApp(t, s, "bot"), "--concurrency", "3") require.NoError(t, err, out) } diff --git a/internal/connector/driver/drivertest/drivertest.go b/internal/connector/driver/drivertest/drivertest.go index 8b43db983..05e071eaf 100644 --- a/internal/connector/driver/drivertest/drivertest.go +++ b/internal/connector/driver/drivertest/drivertest.go @@ -1,12 +1,12 @@ //go:build unix // Package drivertest is the shared way to test the connector's one-owner -// rule: a task's process tree, its working directory or worktree, and its -// ledger record have a single owner and a single release point (see the rule +// rule: a task's process tree, its working directory, and its ledger record +// have a single owner and a single release point (see the rule // written out in internal/connector/driver/worker.go). // -// Cards that start workers, remove worktrees or settle records use these -// helpers rather than each writing their own process fixtures. +// Cards that start workers or settle records use these helpers rather than +// each writing their own process fixtures. package drivertest import ( diff --git a/internal/connector/driver/worker.go b/internal/connector/driver/worker.go index cafe23371..30bd1b9d8 100644 --- a/internal/connector/driver/worker.go +++ b/internal/connector/driver/worker.go @@ -22,16 +22,16 @@ const pipeWaitDelay = 2 * time.Second // # One owner, one release point // // This is the connector's rule for a task's process tree, its working -// directory (or worktree), and its ledger record. All three belong to one -// owner — the attempt — and are released at one point, in this order: +// directory, and its ledger record. All three belong to one owner — the +// attempt — and are released at one point, in this order: // // 1. Every worker starts as the leader of its own process group // (StartWorker), so the tree it makes can be signaled as one. // 2. A cancel, a deadline or a shutdown ends that group: SIGTERM, a bounded // wait, then SIGKILL, by process group id and never by name (Terminate). // 3. The group is then CONFIRMED gone (ConfirmGroupGone). Only after that -// may the attempt be settled, its directory or worktree released, and its -// record made terminal. +// may the attempt be settled, its directory released, and its record +// made terminal. // 4. A group that cannot be confirmed gone — members left, a pid whose // identity cannot be established, a platform that cannot say — leaves the // record HELD: live in the ledger, its conversation and directory still @@ -53,8 +53,8 @@ const pipeWaitDelay = 2 * time.Second // only avoid waiting on it (WaitDelay, CloseStdout). Containment is the // sandbox launcher's job, not this rule's. // -// Cards that start workers, remove worktrees or settle records use the -// functions here rather than writing their own. +// Cards that start workers or settle records use the functions here rather +// than writing their own. // // # What a driver promises, and where each promise can still be broken // @@ -609,7 +609,7 @@ func groupProbe(pgid int, err error) error { // ConfirmGroupGone is step 3 of the one-owner rule: it answers whether a // worker's process group is gone, and it is what every caller asks before -// settling an attempt, releasing a working directory or removing a worktree. +// settling an attempt or releasing a working directory. // // It signals the group once more — a worker that ignored SIGTERM gets SIGKILL // — then waits up to grace for the last member to go. A group with members From 3423ac5e90aadd7cf12d541b0227138ae8977e59 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 18 Sep 2026 16:58:17 +0200 Subject: [PATCH 3/4] Comments only: the codex and acp drivers stop describing worktrees MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prose, no behaviour. Both files are in directories other agents are working in, so this is a commit of its own and can be dropped whole. The codex driver's point about its sandbox stands — it is policy, not containment — but the consequence it drew was about a worktree's git data. The acp adapter's read seam is still there and still nil-able; nothing passes it a reader now that doctor reads the route itself. --- internal/connector/driver/acp/adapters.go | 5 +++-- internal/connector/driver/codex/codex.go | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/internal/connector/driver/acp/adapters.go b/internal/connector/driver/acp/adapters.go index 04d060f07..76e34d7fa 100644 --- a/internal/connector/driver/acp/adapters.go +++ b/internal/connector/driver/acp/adapters.go @@ -64,8 +64,9 @@ type Adapter struct { // run with configuration the connector cannot switch off: nil when there is // none to check. read is how it reads a configuration file, os.ReadFile // when nil — the seam a caller stands in when the session's working - // directory does not exist yet (doctor, which reads a planned worktree's - // files out of the repository it would be made from). + // directory is not where the files to check are. Doctor stood in it to + // read a planned worktree's files out of the repository; with worktrees + // gone, every caller passes nil and reads the working directory itself. Preflight func(cwd string, lookup func(string) (string, bool), read func(string) ([]byte, error)) error } diff --git a/internal/connector/driver/codex/codex.go b/internal/connector/driver/codex/codex.go index 083838ae1..fab018fe0 100644 --- a/internal/connector/driver/codex/codex.go +++ b/internal/connector/driver/codex/codex.go @@ -55,9 +55,9 @@ // approvals set to never, so whatever the sandbox would refuse is refused // without asking anyone. That is still policy, not containment: the sandbox // is Codex's, not the connector's. One consequence is worth knowing: a -// worktree's git data lives outside the working directory, so a Codex worker -// cannot commit, and a Codex task that edits anything ends with its worktree -// kept. Codex's sandbox reads the whole filesystem, but runs the model's +// worker cannot write a repository whose git data lives outside the working +// directory, so it cannot commit there. Codex's sandbox reads the whole +// filesystem, but runs the model's // shell in a PID namespace of its own, so the processes outside it — MCP // servers among them — are not visible to it. package codex From b1b70b0f4e5b637fc9089ac8e7112f5b06a7639e Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 18 Sep 2026 17:40:18 +0200 Subject: [PATCH 4/4] Read a legacy refused-start reply as the reason it was written for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot on #761. An upgraded ledger still holds the holding replies a connector that made worktrees wrote — `holding_reply:refused:event:*`, over records blocked `route_unusable` — and nothing produces that reason now or clears it on a record's behalf. Reading every holding reply as `no_route` got both of those rows wrong. Pending: the claim asked whether the record was still blocked `no_route`, it was blocked `route_unusable`, so the reply was canceled "no longer called for". The record is still blocked and still needs the person that reply was going to ask, and nothing tells them. Sent, then redispatched: worse, and the opposite way round. A redispatch of a blocked record leaves it blocked — it authorizes what blocked it to run again (ledger_decisions.go, `case StateBlocked`) — so the ask is still open when the retraction is claimed. askStillOpen compared against `no_route`, answered "not open", and the retraction went out saying event 1 "is not waiting for it now" to a person it is still waiting for. That is the thing askStillOpen's own comment exists to prevent. holdingReplyReason comes back and both reads go through it: the key says which reason the reply answers. Only reads — nothing writes a refused key now. Same shape as migration 9, which stays in the list byte for byte, and as connect.json's LegacyWorktrees: a row is read as it was written, and what is written now is written the new way. Both states are tested against a real upgraded ledger, and each fails without the mapping — pending canceled with nothing posted, sent posting its retraction over a record still blocked. --- internal/connector/lifecycle.go | 6 +- internal/connector/outbox.go | 32 ++++++++++ internal/connector/outbox_run.go | 14 ++--- internal/connector/retraction_test.go | 85 +++++++++++++++++++++++++++ 4 files changed, 126 insertions(+), 11 deletions(-) diff --git a/internal/connector/lifecycle.go b/internal/connector/lifecycle.go index f7ff48f40..cd45342dc 100644 --- a/internal/connector/lifecycle.go +++ b/internal/connector/lifecycle.go @@ -12,8 +12,6 @@ import ( "strconv" "strings" "time" - - "github.com/basecamp/basecamp-cli/internal/connector/admission" ) // Lifecycle messages are fixed forms. Every word comes from this file; every @@ -172,7 +170,7 @@ func renderRetraction(kind MessageKind, eventID int64, others []int64, postedAt // // The question is asked of the notice that made the ask, not of the record in // general, because "waiting" is not one state. A holding reply answers one -// blocked reason, as its own claim does: a record whose +// blocked reason, as its own claim does (holdingReplyReason): a record whose // rerun replaced no_route with read_failed or throttled is not waiting on a // person at all — those reasons come round again on their own — so that ask // is answered and the record moving between them is the answer. A completion @@ -185,7 +183,7 @@ func askStillOpen(ctx context.Context, q Tx, source Intent, eventID int64) (open switch source.Kind { case IntentHoldingReply: query = `SELECT state = 'blocked' AND reason = ?2 FROM events WHERE id = ?1` - args = append(args, string(admission.ReasonNoRoute)) + args = append(args, holdingReplyReason(source)) case IntentCompletion: query = ` SELECT e.state = 'completed' AND e.redispatch_decision IS NULL diff --git a/internal/connector/outbox.go b/internal/connector/outbox.go index 6f04c10c5..453580f17 100644 --- a/internal/connector/outbox.go +++ b/internal/connector/outbox.go @@ -8,6 +8,8 @@ import ( "strconv" "strings" "time" + + "github.com/basecamp/basecamp-cli/internal/connector/admission" ) // The outbox: every message the connector itself posts to Basecamp — the @@ -390,6 +392,36 @@ func holdingKey(eventID int64) string { return string(IntentHoldingReply) + ":event:" + strconv.FormatInt(eventID, 10) } +// legacyRefusedStartKey is the key a connector that made worktrees gave the +// holding reply for a record whose route could take no worktree. Nothing +// writes one now — the refusal that called for it went with worktrees — but +// an upgraded ledger still holds the ones that build wrote, pending or sent, +// over records still blocked legacyReasonRouteUnusable. They are read as they +// were written; only new rows are written the new way. +func legacyRefusedStartKey(eventID int64) string { + return string(IntentHoldingReply) + ":refused:event:" + strconv.FormatInt(eventID, 10) +} + +// legacyReasonRouteUnusable is the blocked reason those records carry. +// Nothing blocks a record with it any more, and nothing clears it on their +// behalf: a record an upgraded ledger carries is still waiting for the person +// its notice asked, and both the send and the retraction have to know that. +const legacyReasonRouteUnusable = "route_unusable" + +// holdingReplyReason is the blocked reason a holding reply answers for. Its +// key says which: the one an older build wrote answers route_unusable, and +// everything written now answers no_route. Reading every holding reply as +// no_route cancels a legacy reply that is still called for, and tells a +// person an ask is answered while its record is still blocked on it — a +// redispatch of a blocked record leaves it blocked, so the reason is the +// whole of the question. +func holdingReplyReason(in Intent) string { + if in.Key == legacyRefusedStartKey(in.EventID) { + return legacyReasonRouteUnusable + } + return string(admission.ReasonNoRoute) +} + func completionKey(attemptID string) string { return string(IntentCompletion) + ":attempt:" + attemptID } diff --git a/internal/connector/outbox_run.go b/internal/connector/outbox_run.go index e37b3cace..529784f5f 100644 --- a/internal/connector/outbox_run.go +++ b/internal/connector/outbox_run.go @@ -10,8 +10,6 @@ import ( "sync" "time" - "github.com/basecamp/basecamp-cli/internal/connector/admission" - "github.com/basecamp/basecamp-cli/internal/connector/ndjson" ) @@ -415,13 +413,15 @@ func (l *Ledger) claimIntent(ctx context.Context, skip ...int64) (Intent, bool, } } if in.Kind == IntentHoldingReply { - // The reply answers the one reason a record waits on something - // only a person changes — a project with no route — and is sent - // only while the record is still blocked on it. It is wrong about - // the world the moment that reason is not why the record waits. + // Each reply answers one reason a record waits on something only + // a person changes, and is sent only while the record is still + // blocked on that reason. Which reason is the key's to say + // (holdingReplyReason): what is written now answers no_route, and + // an upgraded ledger can still hold one an older build wrote for + // a route no worktree could be made in. var stillBlocked bool switch err := tx.QueryRowContext(ctx, `SELECT state = 'blocked' AND reason = ? FROM events WHERE id = ?`, - string(admission.ReasonNoRoute), in.EventID).Scan(&stillBlocked); { + holdingReplyReason(in), in.EventID).Scan(&stillBlocked); { case errors.Is(err, sql.ErrNoRows): // No record, nothing to answer for. Canceled rather than // left to be claimed again on every tick. diff --git a/internal/connector/retraction_test.go b/internal/connector/retraction_test.go index 08c8f3b68..d7a9ee363 100644 --- a/internal/connector/retraction_test.go +++ b/internal/connector/retraction_test.go @@ -908,3 +908,88 @@ func TestAsksRedispatchReadsTheAskExactly(t *testing.T) { require.NotEmpty(t, reporting) assert.False(t, asksRedispatch(reporting, 1), "succeeded with no reply reported asks for nothing") } + +// An upgraded ledger's legacy refused-start reply, in both states it can be +// left in, over a record still blocked route_unusable — a reason nothing +// produces any more and nothing clears on the record's behalf. +// +// Both cases turn on the same thing: which blocked reason a holding reply +// answers for is its key's to say. Reading every holding reply as no_route +// is right for everything written now and wrong for everything that build +// wrote, and it is wrong in the direction that says nothing or says the +// wrong thing rather than the direction that says too much. +func TestALegacyRefusedStartReplyIsReadAsTheReasonItWasWrittenFor(t *testing.T) { + // Pending: the connector stopped between writing the reply and sending + // it. The record is still blocked and still needs the person the reply + // was going to ask, so the reply is still called for. + t.Run("pending, and still called for", func(t *testing.T) { + ctx := context.Background() + clock := &obClock{now: time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC)} + ledger := obUpgradedLedger(t, clock, func(t *testing.T, old *sql.DB) { + t.Helper() + seedLegacyRefusedStart(ctx, t, old, IntentPending) + }) + basecamp := newFakeBasecamp(clock.Now) + ob := obOutbox(t, ledger, basecamp) + require.NoError(t, ob.Flush(ctx)) + + in := obIntent(t, ledger, legacyRefusedStartKey(1)) + assert.Equal(t, IntentSent, in.State, "a reply whose record is still blocked on its own reason is sent, not canceled") + assert.Empty(t, in.Note) + assert.Len(t, basecamp.at(in.Destination), 1) + }) + + // Sent, then redispatched. A redispatch of a blocked record leaves it + // blocked — it authorizes the thing that blocked it to run again — so + // the ask is still open at the retraction's claim, and a retraction sent + // now tells a person that a record still waiting for them is not. + t.Run("sent, and its ask still open after a redispatch", func(t *testing.T) { + ctx := context.Background() + clock := &obClock{now: time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC)} + ledger := obUpgradedLedger(t, clock, func(t *testing.T, old *sql.DB) { + t.Helper() + seedLegacyRefusedStart(ctx, t, old, IntentSent) + }) + basecamp := newFakeBasecamp(clock.Now) + ob := obOutbox(t, ledger, basecamp) + + sent := obIntent(t, ledger, legacyRefusedStartKey(1)) + require.Equal(t, IntentSent, sent.State) + + clock.Advance(12 * time.Minute) + _, err := ledger.Redispatch(ctx, 1, "jorge") + require.NoError(t, err) + require.Equal(t, StateBlocked, getRecord(t, ledger, 1).State, "authorized, and still blocked on route_unusable") + + clock.Advance(RetractionWait) + require.NoError(t, ob.Flush(ctx)) + assert.Equal(t, IntentPending, obRetraction(t, ledger, sent, 1).State, + "the ask is still open, so nothing says it is answered") + assert.Empty(t, basecamp.at(sent.Destination), "and nothing is posted under it") + }) +} + +// seedLegacyRefusedStart writes the record and the reply a connector that +// made worktrees left behind: an event blocked route_unusable, and the +// refused-start holding reply for it in state. +func seedLegacyRefusedStart(ctx context.Context, t *testing.T, old *sql.DB, state IntentState) { + t.Helper() + _, err := old.ExecContext(ctx, ` +INSERT INTO events (id, state, reason, lane, event_type, kind, action, bucket_id, creator_id, recording_id, + created_at, seen_at, updated_at) +VALUES (1, 'blocked', 'route_unusable', 'import', '', '', '', 48699913, 26909558, 10304028972, + '2026-09-17T11:00:00.000000000Z', '2026-09-17T11:00:00.000000000Z', '2026-09-17T11:00:00.000000000Z')`) + require.NoError(t, err) + + sendingAt, finishedAt, receiptID := "NULL", "NULL", "NULL" + if state == IntentSent { + sendingAt, finishedAt, receiptID = `'2026-09-17T11:31:00.000000000Z'`, `'2026-09-17T11:32:00.000000000Z'`, "556" + } + _, err = old.ExecContext(ctx, ` +INSERT INTO outbox (id, intent_key, kind, state, event_id, bucket_id, message_kind, recording_id, body, + created_at, not_before, sending_at, finished_at, receipt_id) +VALUES (8, 'holding_reply:refused:event:1', 'holding_reply', ?1, 1, 48699913, 'comment', 10304028989, ?2, + '2026-09-17T11:30:00.000000000Z', '2026-09-17T11:30:00.000000000Z', `+sendingAt+`, `+finishedAt+`, `+receiptID+`)`, + string(state), refusedStartReplyAsItWentOut(1)) + require.NoError(t, err) +}