From: Christian Brauner <brauner@kernel.org>
To: "Kernel.org Tools" <tools@kernel.org>
Cc: Konstantin Ryabitsev <konstantin@linuxfoundation.org>,
"Christian Brauner (Amutable)" <brauner@kernel.org>
Subject: [PATCH RFC v2 04/25] review: guard the tracking-commit amend on the worktree, not the checkout
Date: Wed, 12 Aug 2026 23:46:45 +0200 [thread overview]
Message-ID: <20260812-work-b4-multiver-rows-v2-4-305d53cd723a@kernel.org> (raw)
In-Reply-To: <20260812-work-b4-multiver-rows-v2-0-305d53cd723a@kernel.org>
save_tracking_ref() rewrites a review branch's tip with commit-tree plus
update-ref. Unlike `git branch -f`, that pair will move a branch out
from under a live worktree and strand an in-progress `git am` or rebase
on a commit that is no longer the branch tip.
"Checked out" is the wrong test for it. The amend reuses the branch's
own tree, so a quiescent checkout survives one untouched: HEAD moves, the
working tree and index still match it, `git status` stays clean.
Refusing every checkout would refuse the review UI amending the tracking
commit on the branch it has just checked out itself, and would defer the
sweep's own writes for as long as a series stays under review.
What the amend cannot survive is an operation in flight. A `git am`,
rebase, merge, cherry-pick, revert or bisect keeps state that names the
tip being replaced, and git records that per worktree.
Put the rule in the writer rather than in each caller, so the two writers
that reach update-ref through _store_thread_blob() and
ensure_thread_context_blob() are covered as well. A lookup that fails
counts as busy, since this guards a ref move.
Signed-off-by: Christian Brauner (Amutable) <brauner@kernel.org>
---
src/b4/__init__.py | 60 +++++++++++++++++++++++++++++++++++++++++++++++
src/b4/review/_review.py | 20 ++++++++++++++++
src/b4/review/tracking.py | 7 ++++++
3 files changed, 87 insertions(+)
diff --git a/src/b4/__init__.py b/src/b4/__init__.py
index a52b0011..169547c0 100644
--- a/src/b4/__init__.py
+++ b/src/b4/__init__.py
@@ -4601,6 +4601,66 @@ def git_branch_checked_out(gitdir: Optional[str], branch_name: str) -> bool:
return False
+# What a git operation leaves in a worktree's own gitdir while it is in
+# flight. Each of these names a sequencer or a state that remembers the
+# tip the operation started from.
+_WORKTREE_OP_STATE = (
+ 'rebase-apply', # git am, git rebase --apply
+ 'rebase-merge', # git rebase --merge / -i
+ 'MERGE_HEAD',
+ 'CHERRY_PICK_HEAD',
+ 'REVERT_HEAD',
+ 'BISECT_LOG',
+ 'sequencer',
+)
+
+
+def git_worktree_busy(gitdir: Optional[str], branch_name: str) -> bool:
+ """Whether a git operation is in flight where *branch_name* is checked out.
+
+ The question anything rewriting a branch's tip commit in place has to
+ ask, and it is narrower than :func:`git_branch_checked_out`. An amend
+ that reuses the branch's own tree moves HEAD and nothing else -- the
+ working tree and index still match it, and `git status` stays clean --
+ so a quiescent checkout survives one untouched. Refusing every
+ checkout instead would refuse the review UI amending the tracking
+ commit on the branch it has just checked out itself, which is the
+ common and correct case.
+
+ What such an amend cannot survive is a `git am`, rebase, merge,
+ cherry-pick, revert or bisect in flight: those keep state naming the
+ tip they started from, and moving it out from under them strands the
+ operation.
+
+ Not knowing counts as busy. This guards a ref move, so a lookup that
+ fails must not read as permission.
+ """
+ wantref = f'refs/heads/{branch_name.removeprefix("refs/heads/")}'
+ ecode, out = git_run_command(gitdir, ['worktree', 'list', '--porcelain'])
+ if ecode != 0:
+ logger.debug('Could not list worktrees, assuming %s is busy', branch_name)
+ return True
+ wtpath = None
+ current = None
+ for line in out.splitlines():
+ if line.startswith('worktree '):
+ current = line[9:].strip()
+ elif line.startswith('branch ') and line[7:].strip() == wantref:
+ wtpath = current
+ break
+ if not wtpath:
+ # Checked out nowhere, so there is no operation to strand.
+ return False
+ ecode, out = git_run_command(wtpath, ['rev-parse', '--absolute-git-dir'])
+ if ecode != 0:
+ logger.debug('Could not resolve the gitdir of %s, assuming busy', wtpath)
+ return True
+ wtgitdir = out.strip()
+ return any(
+ os.path.exists(os.path.join(wtgitdir, name)) for name in _WORKTREE_OP_STATE
+ )
+
+
def git_revparse_tag(gitdir: Optional[str], tagname: str) -> Optional[str]:
if not tagname.startswith('refs/tags/'):
fulltag = f'refs/tags/{tagname}'
diff --git a/src/b4/review/_review.py b/src/b4/review/_review.py
index 5fa4d2ab..98f51363 100644
--- a/src/b4/review/_review.py
+++ b/src/b4/review/_review.py
@@ -721,12 +721,32 @@ def save_tracking_ref(
Uses git commit-tree + git update-ref so that commit.gpgsign and
hooks are not triggered — tracking commits are ephemeral and do
not benefit from signing. Returns True on success.
+
+ Declines while the worktree holding *branch* has a git operation in
+ flight. update-ref, unlike `git branch -f`, will happily move a
+ branch out from under a live worktree, and a `git am` or rebase keeps
+ state naming the tip this is about to replace. The rule lives here
+ rather than in each caller because it is a property of the write:
+ every caller that moves this ref has to respect it, and the two that
+ reached update-ref through :func:`b4.review.tracking._store_thread_blob`
+ and :func:`b4.review.tracking.ensure_thread_context_blob` did not.
+
+ The tree is the branch's own, so a *quiescent* checkout is not a
+ reason to decline -- see :func:`b4.git_worktree_busy`.
"""
if not branch.startswith(REVIEW_BRANCH_PREFIX):
logger.critical(
'Refusing to write tracking commit to non-review branch: %s', branch
)
return False
+ if b4.git_worktree_busy(topdir, branch):
+ # Said out loud, not at debug: :func:`save_tracking` turns a False
+ # into `Unable to amend tracking commit` and exits, and a maintainer
+ # who has a rebase in flight on the branch is owed the reason. The
+ # sweep is the one caller that meets this routinely, and it runs
+ # inside _quiet_cron/_quiet_worker.
+ logger.info('%s is mid-operation, not amending its tracking commit', branch)
+ return False
commit_msg = cover_text + '\n\n' + make_review_magic_json(tracking)
ecode, out = b4.git_run_command(topdir, ['rev-parse', f'{branch}^{{tree}}'])
if ecode > 0:
diff --git a/src/b4/review/tracking.py b/src/b4/review/tracking.py
index 710b4a0d..2722e6c3 100644
--- a/src/b4/review/tracking.py
+++ b/src/b4/review/tracking.py
@@ -1223,6 +1223,13 @@ def sync_revisions_catalog_to_branch(
that cannot be re-derived from lore — travels with the branch on push.
No-op (returns False) when there is no topdir, no such branch, or the
catalog is already current.
+
+ A branch whose worktree is mid-operation is declined by
+ :func:`b4.review.save_tracking_ref` itself, so there is no test for it
+ here: the rule belongs to the write, and repeating it per caller is
+ how it came to be enforced for this one and not for the other two.
+ The catalog is mirrored again on the next sweep that finds the
+ worktree free.
"""
if not topdir:
return False
--
2.53.0
next prev parent reply other threads:[~2026-08-12 21:47 UTC|newest]
Thread overview: 26+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-08-12 21:46 [PATCH RFC v2 00/25] review: track and browse every version of a tracked series Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 01/25] review-tui: fix rethreaded series thread viewing Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 02/25] review: do not clear fields a re-adding caller does not know Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 03/25] review-tui: keep the rethread flag on an upgraded series row Christian Brauner
2026-08-12 21:46 ` Christian Brauner [this message]
2026-08-12 21:46 ` [PATCH RFC v2 05/25] review-tui: recompute an evicted A·R·T cache entry Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 06/25] review: test the prerequisite fixes Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 07/25] review: serialize schema migrations against a concurrent opener Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 08/25] review: test the migration serialization Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 09/25] review: track message counts for all revisions of a series Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 10/25] review: give per-change_id state its own table Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 11/25] review: test per-revision message tracking Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 12/25] review-tui: poll every revision on u/U updates Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 13/25] review: test the per-revision poll sweep Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 14/25] review-tui: resolve the tracked revision in revision lists Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 15/25] review-tui: fall back when a cached thread blob has no series Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 16/25] review-tui: test revision resolution and the range-diff fallback Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 17/25] review: skip the catalog mirror when nothing moved Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 18/25] review: match a stray posting by message-id Christian Brauner
2026-08-12 21:47 ` [PATCH RFC v2 19/25] review: add backward discovery of older series revisions Christian Brauner
2026-08-12 21:47 ` [PATCH RFC v2 20/25] review-tui: add a "Find older revisions" action Christian Brauner
2026-08-12 21:47 ` [PATCH RFC v2 21/25] review: test the catalog mirror, stray matching and backward discovery Christian Brauner
2026-08-12 21:47 ` [PATCH RFC v2 22/25] review-tui: extract the Msgs column renderer from TrackedSeriesItem Christian Brauner
2026-08-12 21:47 ` [PATCH RFC v2 23/25] review-tui: give the unseen badge a column of its own Christian Brauner
2026-08-12 21:47 ` [PATCH RFC v2 24/25] review-tui: expand tracked series into per-version rows Christian Brauner
2026-08-12 21:47 ` [PATCH RFC v2 25/25] review-tui: test per-version tracker rows Christian Brauner
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20260812-work-b4-multiver-rows-v2-4-305d53cd723a@kernel.org \
--to=brauner@kernel.org \
--cc=konstantin@linuxfoundation.org \
--cc=tools@kernel.org \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.