# w1 · m18 — Root Directory: CRD + build engine + webhook path-filter + API surface

**Worker:** worker1 **Goal:** monorepo support — `App.spec.rootDir` makes the operator build from a subdirectory instead of the repo root, and scopes the git-push auto-deploy webhook to only redeploy when the pushed diff touches that subdirectory, mirroring Render's Root Directory setting. **Status:** DONE (2026-07-10) — CRD field, build engine, webhook path-filter, and REST/GraphQL/MCP surface all shipped; verified live against the real `bex-co/bex` monorepo.

## Tasks (in order)

| id   | title                                                                                                                                     | est | depends_on   |
| ---- | ------------------------------------------------------------------------------------------------------------------------------------------- | --- | ------------ |
| t001 | Add `RootDir` to `AppSpec` (`lego/types/v1alpha1/app_types.go`); `make manifests generate` from `lego/operator/` — **DONE**                            | 30m | —            |
| t002 | Extend `build.Options`/`gitContext()` (`lego/operator/internal/build/build.go`) to append BuildKit's `:<subdir>` git-context suffix when set; extend the existing `gitContext` unit tests — **DONE** | 45m | t001         |
| t003 | Wire `a.Spec.RootDir` into `build.Options` at the controller call site (`lego/operator/internal/controller/app_controller.go`) — **DONE**               | 30m | t002         |
| t004 | Webhook path-scoped auto-deploy: extend `pushEvent` (`lego/backend/internal/apps/webhook.go`) to capture per-commit changed-file paths from the GitHub/Gitea push payload; add a path-prefix filter in `redeployMatching` gated on `spec.rootDir` (empty = today's always-redeploy behavior) — **DONE** | 1h  | t001         |
| t005 | Thread `rootDir` through REST/GraphQL/MCP create+update request/response shapes and CR mapping (`rest.go`, `graphql.go`, `mcp.go`, `service.go`) — **DONE** | 45m | t001         |
| t006 | Update `docs/ADR004-deployment.md`'s existing gotcha (the "no `spec.rootDir` field yet" line) to describe the shipped behavior and its limits (Dockerfile builder only — buildpack/CNB still not in-cluster) — **DONE** | 20m | t002, t004   |
| t007 | Render parity: compare against Render's Root Directory semantics (render.com/docs/monorepo-support#setting-a-root-directory) across REST/GraphQL/MCP/UI, flag drift as follow-up — **DONE**                | 30m | t005, t006   |
| t008 | Simplify: `/simplify` over the code this milestone changed — **DONE**                                                                                    | 30m | t007         |
| t009 | Test coverage: meaningful tests for subdir build-context construction, webhook path-filter matching (in-root vs. out-of-root push), and the API surface field — **DONE**                                    | 45m | t007         |
| t010 | Closeout: verify DoD, mark done, move milestone to `done/` — **DONE**                                                                                     | 15m | t009         |

## Definition of done

An App with `spec.rootDir` set to a subdirectory builds successfully from a monorepo (BuildKit builds only that subdirectory's Dockerfile), verified against a real monorepo test repo; a push whose diff touches files outside `rootDir` does not trigger an auto-deploy while a push touching files inside it does; `rootDir` is settable and readable identically via REST, GraphQL, and MCP.

## Source + Goal linkage

- **Source:** `/pm-brainstorm for w1` (Root Directory topic) 2026-07-09, from Render's monorepo-support docs (https://render.com/docs/monorepo-support#setting-a-root-directory); closes a gap already flagged at `docs/ADR004-deployment.md:157` ("Build-from-git needs a root Dockerfile today ... no `spec.rootDir` field yet").
- **Goal linkage:** Render-parity on the REST/GraphQL/MCP surface (`docs/ADR006-bex-api.md`); natural extension of `GOAL.md` #3 (git push to deploy) and build-from-git (`w1/m5`, done).
- **Expected outcome:** monorepo users point bex at a subdirectory the way they do on Render; unrelated pushes elsewhere in the repo stop causing wasted rebuilds/redeploys.
- **Why now:** `w1/m5` (build-from-git) and `w2/m2` (webhook) are both done and stable in prod — this is the next incremental parity gap on that surface rather than new architecture, and it's already a named known limitation, not invented scope.
- **Render parity: included** (t007) — this milestone touches REST/GraphQL/MCP directly (t005); the dashboard UI half is a separate follow-on milestone (`w5/m13`) since the dashboard has no build/deploy settings section to extend yet.

## Completion notes (2026-07-10)

- **t001-t003 — CRD + build engine**: `AppSpec.RootDir` (`lego/types/v1alpha1/app_types.go`), CRD/deepcopy regenerated. `gitContext(repo, ref, rootDir)` (`lego/operator/internal/build/build.go`) now appends BuildKit's `:<rootDir>` suffix after the `#<ref>`, so the dispatched Job's `context=` arg scopes the git clone to the subdirectory; wired from `app.Spec.RootDir` at the controller's `build.Options{}` call site.
- **t004 — webhook path-filter**: `pushEvent` gained a `Commits[].{added,removed,modified}` field (the shape GitHub/Gitea both send); `rootDirMatches(rootDir, paths)` gates `redeployMatching` — empty `rootDir` is unchanged always-redeploy behavior, and a payload with no commit path data fails open (redeploys) since there's nothing to filter on.
- **t005 — REST/GraphQL/MCP**: `rootDir` threaded through `CreateRequest`/`specFromCreate`/`applyCreateToSpec` (`service.go`), the REST create body + `bex.yml` manifest (`rest.go`, `deploy.go`), GraphQL `createService(rootDir:)` + `Service.rootDir` (`graphql.go`), and both MCP create tools (`mcp.go`). Read-back added to `AppView`/`renderService` so it's visible on `GET`/`get_service`/GraphQL `server` identically (REST and MCP share the same `toRenderService` function, so one code path covers both).
- **t006/t007 — docs**: `docs/ADR004-deployment.md`'s stale "no `spec.rootDir` field yet" gotcha rewritten to describe the shipped behavior + Dockerfile-only limit; `docs/ADR018-render-parity.md` gained a Root Directory row (✅ REST/GraphQL/MCP, ✖ UI — tracked at `w5/m13`).
- **t008 — simplify**: 4-agent pass (reuse/simplification/efficiency/altitude) found no reuse violations, no efficiency issues, and confirmed the flat-field-per-DTO pattern (matching `Repo`/`Branch`/`Builder`) is the established altitude, not a shortcut. Only concrete fix: two `gofmt` alignment drifts (`build_test.go`, `service.go`) from manual edits — applied.
- **t009 — tests**: new `lego/backend/internal/apps/webhook_test.go` (`rootDirMatches` table incl. leading/trailing-slash and sibling-directory-prefix-is-not-a-match cases, `changedPaths` flattening, an end-to-end `redeployMatching` in-root-vs-out-of-root case, and a raw-JSON `commits[]` parse-through-`ServeHTTP` case) and `rootdir_test.go` (REST create+read, GraphQL create+read, both MCP create tools, `bex.yml` manifest, redeploy-reapplies-rootDir). Full `go test ./...` green in both modules.
- **t010 — live verification**: the shared local kind cluster (`kind-bex`) had no operator Deployment running, so rather than mutating that shared, multi-milestone dev environment, verification used `docker buildx build` directly against the **real public `bex-co/bex` monorepo** with the exact context string `gitContext()` produces: `https://github.com/bex-co/bex.git#main:examples/hello-go` — **built successfully** (real git clone of just that subdirectory, real Dockerfile build). Negative control — the identical context with the `:rootDir` suffix dropped (`...#main`, i.e. repo root) — **failed** (`open Dockerfile: no such file or directory`, since bex's repo root has none), proving the feature does real, necessary work rather than incidentally succeeding either way. `make test` from `lego/operator/` (a real envtest apiserver) passed for both `internal/build` (88.1% cov) and `internal/controller` (58.9% cov); a separate Makefile-chain step failed on a pre-existing local Go-toolchain binary mismatch (`go tool version go1.25.1` vs `go1.25.7`) unrelated to this diff — confirmed by re-running `go vet`/`go build` directly, both clean.
