Skip to content

feat: keep PR review worktrees on the current GitHub head - #20

Open
imkarrer wants to merge 5 commits into
mgreau:mainfrom
imkarrer:feat/keep-worktrees-current
Open

feat: keep PR review worktrees on the current GitHub head#20
imkarrer wants to merge 5 commits into
mgreau:mainfrom
imkarrer:feat/keep-worktrees-current

Conversation

@imkarrer

Copy link
Copy Markdown
Contributor

Summary

Review worktrees used to be create-once. After this, an idle checkout tracks GitHub's current head.

Each poll compares headRefOid to worktree HEAD. Linear updates are git fetch into refs/remotes/origin/pr-N (not the checked-out pr-N branch) then git merge --ff-only. Context is rewritten only when HEAD actually moved.

  • Create still requires authors:.
  • Refresh does not — zen review can create those trees and watch still keeps them current.
  • Watch polls every configured repo (no hardcoded chainguard-dev/mono).

Safety

Unattended paths (watch, MCP, zen review --json) never git reset --hard.

Situation What happens
Tracked local edits skip merge; retry next poll
Live agent (session UUID on argv, or claude/codex cwd in the tree) skip; covers a first-pass /review-pr with no UUID yet
Untracked CLAUDE.local.md / .zen/ not dirty; does not block a fast-forward
Force-push / rewritten head daemon and MCP leave the tree; zen review / zen review resume prompt [y/N] (default no). Reflog keeps the old tip.

On macOS, ps -o comm= is the executable path. Live-agent detection uses filepath.Base so /opt/homebrew/bin/claude is not missed.

zen review --json / --no-terminal on an existing worktree refreshes and does not open a tab.

ZEN_HOME overrides ~/.zen so a second daemon or a functional harness does not share config.yaml / last_check.json.

Notifications

  • First sighting, no local worktree → “New PR Review Request”, even if the author is not in authors:.
  • Successful fast-forward → quieter “PR #N updated”.
  • Existing inbox PRs are not re-announced on upgrade (seen_prs is absorbed into notified_new on the first poll, then dropped).

Upgrade

Restart zen watch so the new binary is what polls. config.yaml does not change. Existing worktrees stay in place; the next poll fast-forwards idle ones.

Closes #18.

Functional test

Ran end-to-end against imkarrer/zen-tests (./scripts/keep-worktrees.sh) with an isolated ZEN_HOME so day-to-day ~/.zen is untouched. 21/21 passed (create, A→B FF + context rewrite, dirty skip, live-agent skip, --json no reset, TTY confirm reset, inbox notify without auto-create, seen_prs upgrade, watch create + FF, draft left alone, undraft catch-up without a second “new”).

Merged cleanup is still a unit test (cleanup_after_days cannot be 0).

Test plan

  • Ready at SHA A, push B → worktree + context on B (zen-tests)
  • Ready → draft (ignore_drafts: true) → push C → undraft → one refresh to C, not a second “new review request”
  • Dirty worktree or live agent: files not discarded
  • zen review on an existing directory fetches before opening a tab (--no-terminal / --json do not open a tab)
  • Force-push: watch/CLI --json skip reset; TTY asks
  • Inbox notify for a review request whose author is not in authors: and has no worktree yet
  • Merged cleanup unchanged (unit tests; not in the harness)
  • After upgrade, seen_prs does not re-announce current inbox PRs

Note for reviewers

Conflicts with #13 (git fetch origin vs remote matching full_name). This PR should land first or rebase onto #13 and use RemoteForRepo in FetchPRHead.

Fast-forward idle review checkouts when GitHub's head moves, skip local
edits and live agents, and prompt before reset --hard on rewritten history.
Functional tests and a second daemon need their own config directory. Existing
worktrees were ignoring --json/--no-terminal and always opening a tab.
On Darwin, ps -o comm= is the executable path, not the basename. Live-agent
detection then missed /opt/homebrew/bin/claude and any cwd-based skip.
@imkarrer
imkarrer force-pushed the feat/keep-worktrees-current branch from 62c188b to 221ac05 Compare August 28, 2026 17:39
GitHub Actions has no user.name; commits in a fresh clone were failing.
@imkarrer
imkarrer marked this pull request as ready for review August 28, 2026 17:49
@mgreau

mgreau commented Aug 30, 2026

Copy link
Copy Markdown
Owner

thanks, good idea as i ended up having to ask claude or codex to pull the latest updates after a zen review resume.

I've merged another PR and therefore there are some conflicts, can you please resolve them. thanks

Keep worktree refresh (PollMemory, HeadRefOid, ZEN_HOME) together with
Slack polling, zen board Kind tags, and CreateFromPR.

@mgreau mgreau left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The shared refresh path, dirty-worktree checks, and cross-repository PR keys are useful improvements. The full Go test suite, go vet, and targeted race tests passed locally. I found three issues to fix before merging, detailed inline: noninteractive hard resets, false update reports after a backward force-push, and configured PRs being hidden by the global search limit.

For release sequencing, I plan to land this after #23/#24, then rebase #13 onto it. The #13 integration must select the configured repository's remote in both CreateFromPR and the new FetchPRHead/FetchRefspec refresh path. Updating only creation would leave refresh fetching from origin in fork clones.

When #25 lands, the new worktree lookup in watch.pollOnce must also use worktree.Resolve; otherwise nested worktrees created for authors outside the allowlist are treated as missing and never refreshed. Please preserve that requirement during the rebase.

Please add regression coverage for the cases below. Release notes should also tell users to restart zen watch so the new refresh behavior is active.

Comment thread cmd/review.go
// confirmResetWorktree asks before git reset --hard onto a rewritten GitHub
// head. --json never resets (no TTY). Default is no.
func confirmResetWorktree(req review.ResetRequest) bool {
if jsonFlag {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Require an actual terminal before offering a hard reset. This only checks --json; fmt.Scanln below also accepts piped input. In a disposable repository with diverged PR history, I ran the built CLI with piped yes and --no-terminal, and it executed reset --hard without a TTY. Please reject noninteractive stdin here and treat input errors/EOF as a declined reset. Add a regression case with piped input, alongside the interactive confirmation case.

Comment thread internal/review/sync.go
return maybeReset(gitCtx, worktreePath, prNumber, log, confirmReset)
}
log(fmt.Sprintf("fast-forwarded PR #%d worktree", prNumber))
return SyncUpdated, nil

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Verify that HEAD actually reaches the fetched target before returning SyncUpdated. If the PR is force-pushed backward from B to ancestor A, git merge --ff-only A exits successfully with "Already up to date" and leaves B checked out. I reproduced that behavior. This path then reports a successful update, rewrites context, and can send another update notification on every poll, while the CLI never offers the reset needed to reach A. Detect this ancestor/ahead case and route it through the same reset-or-skip policy as diverged history; report SyncUpdated only when HEAD moved to the target. Please cover both a backward force-push and local commits ahead of the fetched ref.

Comment thread cmd/watch.go
func pollOnce(ctx context.Context, seenPRs map[string]bool, queue workqueue.Interface, rec *reconciler.SetupReconciler) {
reviews, err := ghpkg.GetReviewRequests(ctx, "chainguard-dev/mono", cfg.IgnoreDrafts)
func pollOnce(ctx context.Context, st *reconciler.PollMemory, queue workqueue.Interface, rec *reconciler.SetupReconciler) {
reviews, err := ghpkg.GetReviewRequests(ctx, "", cfg.IgnoreDrafts)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Scope or paginate the search before filtering repositories locally. Passing an empty repo filter makes both GetReviewRequests searches global, but each query still uses first: 50 without pagination. Requests in unconfigured repositories can occupy the returned slots, so a configured repository's PR can disappear from this poll even when it would have appeared in the previous repository-scoped query. That PR then receives neither setup nor refresh. Query configured repositories and handle pagination, or paginate a suitably scoped combined search. Please cover more than 50 results, including requests from unconfigured repositories.

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.

keep PR worktrees on the current head

2 participants