Skip to content

fix(git): point the Changes tab and file browser at the directories they describe (#710) - #715

Open
edspencer wants to merge 2 commits into
mainfrom
fix/710-worktree-changes-tab
Open

fix(git): point the Changes tab and file browser at the directories they describe (#710)#715
edspencer wants to merge 2 commits into
mainfrom
fix/710-worktree-changes-tab

Conversation

@edspencer

Copy link
Copy Markdown
Owner

Verifies #710 — a project whose workingDir is a linked git worktree — and fixes what that turned up.

The headline is that most of the surface was already right. status, diff and commit act on the worktree; the branch shown is the worktree's own; a commit lands on that branch and leaves the main checkout untouched; the grid's "N uncommitted" badge already covers linked projects through dirtyCountAt. Three things had been left behind when #709 moved status/diff/commit onto workingDir, and each failed silently rather than loudly. All three are visible on any project whose working directory isn't its metadata directory — a worktree, a linked checkout, or a plain repo-backed clone.

1. Every new file in the Changes tab rendered "File not found"

An untracked file has no diff, so the pane renders its content instead — and it fetched that from /files/:name, the metadata-dir browser. For a linked or repo-backed project that is a different directory, so every untracked row 404'd. Reproduced on both shapes; a notebook project (where the two directories coincide) was the control that still worked.

The pane now reads a new GET /api/projects/:slug/git/file, served from the working directory and gated on git's own answer: a path git status reports as untracked, and nothing else.

That gate is deliberately not the dot-segment guard the notes browser uses, which is wrong here in both directions — the security-adjacent question the ticket asked to decide rather than leave to chance:

  • It under-blocks. A worktree's .git is a file, not a directory, so it is the leaf — and a dotfile leaf is deliberately allowed there (an untracked .gitignore has to render, per Files tab: browse subdirectories with nested URLs (flat top-level-only today) #259). Serving it would disclose the main checkout's gitdir path for no reason. The real config is safe either way: it lives outside the working directory, so containment already refuses it.
  • It over-blocks. A brand-new .github/workflows/ci.yml is an ordinary row the pane lists, and a dot-directory rule refuses to render exactly that.

Git never reports .git, and never reports an ignored path, so .chats/ and anything the repo excludes stay unreadable — while .github/… renders. The servable set is by construction the set the pane already displays, rather than a third notion of "allowed" to keep in step with the other two. The notes surface keeps its existing guard unchanged; the working-dir reads opt out of it explicitly, with the git-verified allowlist in hand.

2. The Push button pushed the wrong repository

remote() and push() were still hard-wired to projectsRoot while the header beside them showed the project's own branch. On a linked project the header read feat/ad-detector · 3 uncommitted · [Push ↑N] where the branch was the worktree's and the ahead-count, remote URL and Push target were Paddock's own notes repo. In the test rig the store had no remote so the button merely sat disabled; on a real box the store does have one, and pressing it pushes the notes repo while the user believes they are pushing their worktree.

Both methods take a directory now, defaulting to the store, and the pane asks GET /api/projects/:slug/git/remote / POST /api/projects/:slug/git/push about its own working directory. The GitHub device-flow connection is genuinely fleet-level and rides along in the same payload, so the header is still one fetch. For a notebook project the working directory is inside projectsRoot, so this resolves to the same repo, branch and upstream as before — a strict generalisation.

3. A managed project with a path: had an empty Files tab

contentDirFor puts a managed project's curated CLAUDE.md / OVERVIEW.md / CHANGELOG.md out at the nominated path, but the browser joined projectsRoot + slug and listed a directory holding only project.yaml; /files/CLAUDE.md 404'd. project-files.ts now takes a resolved directory instead of (root, slug) and the store passes the content dir — the same resolution the sweeper writes through — so there is one copy of it rather than two. Unmanaged and notebook projects are unchanged.

The other ticket items, confirmed working

  • isRepo() ancestor-walk + cache (item 4). Nothing reports repo-ness for the wrong repo. repoAt is keyed by resolved path, and a non-repo linked directory stays a non-repo even while both the store and the worktree are repos. A linked path that is a subdirectory of some other repo does light up git for that enclosing repo, scoped correctly to the subtree by --show-prefix / --relative — which is a reasonable reading of "you pointed at a directory inside a repo", and now has a test pinning it.
  • Branch display (item 5). --abbrev-ref HEAD is per-worktree and shows feature-x while the main checkout sits on main. With feat: project 'overview' + auto-update sweep (post-turn, rate-limited) #2 fixed, the whole header now describes one repository.
  • dirtyCounts (item 3). Linked projects are not missing from the badge — feat!: managed/unmanaged projects, a path: on both axes, and the Changes tab on workingDir (#206, #597) #709 already added the dirtyCountAt(workingDir) fallback for exactly the projects the cheap store-wide sweep structurally cannot see. Verified end to end, with a test so it stays that way. No change needed.

Testing

packages/server/test/integration/git-worktree.test.ts drives the real routes against a real worktree with a real bare origin: 14 tests, of which 7 fail on main and 7 are confirmations of what already worked. Full suite green (1932 server, 946 web), typecheck clean, OpenAPI spec regenerated (+3 routes).

Also QA'd live against a built instance with a genuine linked worktree — the screenshots in the issue thread show the header on feat/ad-detector and an untracked .github/workflows/ci.yml rendering, which is both the 404 and the over-blocking case in one row.

Out of scope, as the ticket specified

Transcript relocation for Claude-Code-native worktrees (adjacent to #708) and branch-scoped chats (#213) are untouched. Nothing here needs either: a linked worktree is just a directory to Paddock, and ensureProjectChats keys on workingDir as it always has.

Closes #710

Paddock added 2 commits August 6, 2026 23:23
…hey describe

Verifying #710 (a project whose workingDir is a linked git worktree) turned up
three surfaces left behind when #709 moved status/diff/commit onto workingDir.
Each failed silently, and all three are visible on any project where the working
directory is not the metadata directory — a worktree, a linked checkout, or a
plain repo-backed clone.

- The Changes tab's new-file view fetched an untracked file's content from
  /files/:name, the METADATA-dir surface, so every untracked row in the pane
  rendered 'File not found'. It now reads GET /git/file, which serves from the
  working directory and gates on git's own answer: a path git reports as
  untracked, and nothing else. That is both tighter than the dot-segment guard
  (.git is never reported, in a worktree it is a FILE and so would have passed
  as a leaf, disclosing the main checkout's gitdir; ignored files are never
  reported either) and looser where it should be (a new .github/workflows/ci.yml
  is an ordinary row the pane lists and must be able to render).

- Remote and push were still hard-wired to projectsRoot while the header beside
  them showed the project's own branch, so the Push button offered to push
  Paddock's notes repo. Both take a directory now, defaulting to the store, and
  the pane asks about its own working directory. A notebook project resolves to
  the same repo as before.

- The file browser joined projectsRoot + slug, which is the content dir only
  when the project is unmanaged or a notebook. A MANAGED project with a path:
  keeps its curated trio out at that path, so its Files tab listed nothing but
  project.yaml. It now browses contentDir — the same resolution the sweeper uses.

The dot-segment guard on the notes surface is unchanged; the new working-dir
reads opt out of it explicitly, with a git-verified allowlist in hand.

Refs #710
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying paddock with  Cloudflare Pages  Cloudflare Pages

Latest commit: e4907a2
Status: ✅  Deploy successful!
Preview URL: https://ab3f8e98.paddock-7u2.pages.dev
Branch Preview URL: https://fix-710-worktree-changes-tab.paddock-7u2.pages.dev

View logs

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Verify the Changes tab and file browser when a project's workingDir is a git worktree (deferred from #709)

1 participant