Workspace projects: final design — root-as-repo, change propagation, full lifecycle #164
harshitsinghbhandari
started this conversation in
Ideas
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
-
Workspace projects: final design — root-as-repo, change propagation, full lifecycle
This supersedes the root-file sections of
docs/workspace-projects.mdand consolidates the resolution thread in #161 into one implementable spec. Follow-up to #155 and #161. Every git-mechanical claim below was verified with live git; the lifecycle rules also fix two bugs found while auditing the #161 thread (a stash-on-conflict failure and a two-dot/three-dot diff error — see §10).What stays settled from #155/#161: composed child git worktrees, no submodules, no
--targets, every session gets all registered child repos, PR ownership stays session-centric.What changes: root files are no longer "read-only symlink context with enforcement." The parent folder becomes a git repo. Everything is a worktree.
1. The model in one paragraph
A workspace project's parent folder is a git repo (
root-as-repo) whose.gitignoreexcludes every child-repo path. A session is then uniformly N+1 worktrees: one parent worktree at the session root holding the root files (package.json,Makefile,scripts/, …) plus one child worktree per registered repo nested inside it — all on the same branch name. Root files get full git semantics — isolation per session, history, diff/review, rollback, and merge-based concurrency — from the same mechanism as everything else. The symlink layer, the root-write lock, and theroot_accessmode are deleted.Why this beats the alternatives (full analysis in #161): the root files named by this feature are written by the normal toolchain (
pnpm installrewrites the root lockfile; builds write root caches). Symlinks turn that common benign event into silent cross-session corruption of canonical, patched by enforcement that is admittedly not a barrier. A per-session copy fixes corruption but adds drift plus a second isolation model and a promotion path. Root-as-repo needs neither: a tool write is an uncommitted change in an isolated worktree; an intentional root edit is a commit; sharing is a merge. The invasiveness (AO writes.gitinto the parent) is explicitly accepted.2. Registration (
ao project add --path <parent> --as-workspace)Child detection is unchanged: direct child git repos are registered; non-git children skipped.
Child validity — strict, with auto-fix offer. Each child must have ≥1 commit and an identifiable default branch; bare repos and directories that are worktrees of an external repo are rejected. On failure AO offers the fix (
git init -b main, initial commit) instead of hard-refusing.Parent setup:
.gitignore(never overwrite), commit that edit. Never re-init; never require further cleanliness beyond the gitignore commit being possible.git init -b main, write.gitignorecontaining every child path plus a junk denylist (node_modules/,dist/,build/,.cache/,.turbo/,target/, …), then a single initial commit of the root files.git add. An un-ignored child dir gets recorded as an embedded gitlink (160000) — a broken submodule — and.gitignorecannot untrack it afterwards (verified).160000entry. This guard is code, not convention.3. Spawn
Provision order: parent worktree first (
git -C <parent> worktree add <session-root> -b <branch> <base>), then each child worktree inside it. The parent worktree materializes only tracked root files; ignored child paths are absent, leaving them free for the childworktree addcalls. Onesession_worktreesrow per repo, parent included (reserved repo name__root__or aprojects.root_is_repoflag — implementer's choice, but the root must flow through the same row machinery).Base ref: each repo (parent and children) branches off its local default branch tip. No fetch at spawn; deterministic and offline. Staleness vs the remote is accepted — users who want latest pull first. Store the resolved base SHA per row.
Branch name — one-shared-name invariant: default
ao/<session-id>in every repo. If that name already exists in any repo (possible because branches are retained for restorability, §5), compute one suffixed name (ao/<session-id>-2, incrementing) that is free in all repos and use that same name everywhere. Name selection is done once, under the daemon's spawn serialization (no probe race). The name is stored per row; never derived.Partial provision failure: if child N fails, remove the already-created worktrees (children first, then parent) and leave no registered half-session.
4. How changes travel (the N+1 fan-out)
A session that touches
cli/andpackage.jsonhas changed two unrelated repos. The deliverables travel on different rails and do not move together:4.1 Children — unchanged
Commit on the session branch → push to the child's origin → PR → review/CI/merge on the SCM. Exactly today's flow, times N.
4.2 Root — detection (local, three checks)
The parent usually has no remote, so there is no webhook to watch. Detection is local git, possible because all parent worktrees share one object store (a session commit is in the canonical parent the instant it's made — no push). At session completion (and on demand), the root is "pending" if any of:
git rev-list --count main..ao/<session>> 0;git status --porcelainnon-empty — committed-only detection would silently miss edits the agent never committed, which is exactly the loss this design exists to prevent;The review diff is three-dot:
git diff main...ao/<session>(merge-base diff). Two-dot compares tips and, oncemainhas advanced from another session's land, misleadingly shows the reverse of unrelated landed work (verified — see §10).Gitlink guard (land-time): if the root diff introduces any
160000entry (e.g. the agent cloned a repo into the session root and committed), block the land/PR and surface it. The registration-time guard alone is insufficient because agents run git in the parent worktree for the session's whole life.4.3 Root — delivery, remote-aware
main, surfaced at session completion and executed on explicit confirm — never automatic. Review happens on the three-dot diff in the session detail view.main(pull --ff-onlywhen canonical is on a cleanmain; otherwisefetch origin main:main). Without this, the local-default base policy (§3) hands the next session a stale root base. Falls back to local-land when no remote. Never forces the user to add one.4.4 Land mechanics — never trample the user's checkout
The merge target is
main, which may be checked out in the user's canonical working tree (possibly dirty), and git refuses the same branch in two worktrees. Exact rules:main, clean → merge directly in canonical (the result belongs there anyway);main, dirty → refuse with "commit/stash root changes in , then retry";main→mainis unattached: perform the merge in a temporary worktree ofmain, remove it after; canonical is untouched and sees the result on next switch/pull.4.5 Discard — the third exit
Lockfile-churn-only root diffs will be the common case. The session detail therefore has three root actions: land (or open PR), view diff, and discard — which marks the root row resolved without merging. Nothing is deleted (the branch survives for restorability, §5); discard only clears the pending status. Without this, worst-of-N status (§6) nags forever on trivial sessions.
4.6 Concurrency — merges replace the lock
Two sessions touching root = two branches, landed sequentially; conflicts are git conflicts, handled by §4.4. This replaces
project_root_write_locksandsessions.root_accessentirely. There is no exclusive root access anywhere in the design.5. Lifecycle: preserve primitive + full matrix
5.1 Primitive: per-repo preserved-state ref
git stashwrites a commit into the shared repository, not the worktree, so uncommitted state can outlive worktree removal:Verified round-trip: staged, unstaged, and untracked all survive force-destroy → restore, including the staged/unstaged split (
--index). Each repo has its own ref store, so the same ref name lives independently in the parent and every child — the row key(session_id, repo_name)records which rows hold one..gitignored files are deliberately not captured (that's build junk). The ref is dropped only on a clean reapply or explicit session deletion.Known limitation (fixes a #161 bug):
git stash pushfails on unmerged (conflict) entries and cannot capture in-progress merge/rebase state — verified. Since §5.2's own "reapply with conflicts" row produces that state, destroy must handle it explicitly; see the matrix. "Cleanup always succeeds" is therefore scoped: it always succeeds except for in-progress-operation worktrees (explicit rule below) and OS-level file locks (e.g. Windows open handles), which remain retry-later.5.2 The matrix
worktree remove+ pruneremove --force--force(ignored junk) — safe, since anything preservable was snapshotted<name>.stray-<timestamp>(nothing deleted), recreate5.3 Retention — every session is restorable
There is no permanently-killed session; kill = suspend. Session branches (
ao/…, in every repo) and preserved refs are retained for the session's entire life and removed only on explicit session deletion (delete the branch in every repo + every preserved ref). Accumulation across many sessions is accepted; it is also the only reason branch-name collisions exist, which the suffix rule (§3) absorbs.6. Status aggregation
List view — worst-of-N, root included. One glanceable status; worst wins:
A session is "done" only when every child deliverable is merged and the root is resolved (landed, PR-merged, discarded, or never changed). Terminal-but-bad states (child unavailable, PR closed without merge) rank as needs-attention, not done.
Detail view: per-child PR rows (PR + CI/review state) plus a root row: the three-dot diff with land / open-PR / discard actions per §4.
Cross-repo bulk operations (merge-all / rebase-all / cancel-all): explicitly out of V1.
7. Schema
Deleted from the previous plan:
sessions.root_access,project_root_write_locks, and the root-context manifest table. Workspace support collapses to "N+1 worktrees per session"; the root is not a special case in the schema.8. Accepted tradeoffs (explicit, so nobody relitigates silently)
.git/.gitignore/a commit into a plain parent. Accepted: one uniform correct mechanism beats a non-invasive leaky one.9. Implementation slices (updated)
projects.kind,workspace_repos,--as-workspace, strict child validation + auto-fix offer, parent adopt-or-init with gitignore-first + registration gitlink guard.session_worktreesincl. root row,base_sha,preserved_ref,state.workspace_repos, PR-to-session attribution unchanged, worst-of-N + root-pending aggregation.Slices 1–2 are unblocked today. Slice 3 depends on nothing outside this document. Slices 4–5 depend on 3.
10. Verified git facts (receipts)
Everything load-bearing was tested with live git rather than assumed:
.git, so commands at the session root resolve to the parent and commands insidecli/resolve to cli;git add -Ain the parent never touches ignored children;git clean -fdxin the parent leaves nested child repos alone;git worktree listis per-repo.160000 <sha> cli, and adding it to.gitignoreafterwards does not untrack it. Hence the ignore-first rule and the two guards.log,diff) from the canonical parent with zero remotes configured, and a local merge lands it into the canonical working file.stash push -u→update-ref→worktree remove --force→worktree add→stash apply --index, with the staged/unstaged distinction intact.git stash push -uon a worktree with unmerged entries fails (error: could not write index). This is why the matrix has an explicit in-progress-operation row instead of claiming universal snapshot-ability.mainadvances,git diff main..ao/xshows the reverse of unrelated landed work;git diff main...ao/xshows only the session's changes. The review surface must be three-dot.Decision log (for the record)
ao/<session-id>; on any collision, one suffixed name used in all repos*.stray-<ts>), recreateBeta Was this translation helpful? Give feedback.
All reactions