Skip to content

Commit 92ee3d2

Browse files
authored
Merge branch 'main' into claude/issue-105-assign-reviewer
2 parents 38f5750 + 929e3ba commit 92ee3d2

3 files changed

Lines changed: 185 additions & 5 deletions

File tree

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Selectively clear Quarto's freezer for pages whose source file or included
4+
subfiles changed in this PR.
5+
6+
Why this exists
7+
---------------
8+
The project renders with `freeze: auto`, and CI restores the `_freeze`
9+
directory from a cache so unchanged pages reuse their previously executed
10+
output instead of re-running R. Quarto decides whether a frozen result is
11+
stale by hashing the page's *own* source file only. That misses the case
12+
where a page pulls in another file via a `{{< include ... >}}` shortcode:
13+
when the *included* subfile changes but the parent `.qmd` does not, the
14+
parent's hash is unchanged, so Quarto serves the stale frozen output and the
15+
include's new content never appears in the preview.
16+
17+
This script closes that gap. It diffs the PR against its base branch, maps
18+
each page to the set of files it depends on (itself plus its includes,
19+
resolved recursively), and removes the `_freeze` directory only for pages
20+
whose file or subfiles actually changed. Everything else keeps its cached
21+
freeze, so this stays cheap — it only forces re-execution of the pages that
22+
genuinely need it. The full-rebuild escape hatch (the `clear-freezer` PR
23+
label, which skips the cache restore entirely) is unchanged; this step is
24+
skipped when that label is present.
25+
26+
Environment variables
27+
----------------------
28+
FREEZE_DIR : freezer directory to prune (default: ``_freeze``).
29+
BASE_SHA : base branch commit to diff against (the PR base SHA). When
30+
unset or unresolvable, the script makes no changes and lets
31+
Quarto's own `freeze: auto` handling stand.
32+
"""
33+
34+
import os
35+
import re
36+
import shutil
37+
import subprocess
38+
import sys
39+
from pathlib import Path
40+
41+
# {{< include path >}} / {{< include "path" >}} / {{< include 'path' >}}
42+
# Three capture groups: double-quoted, single-quoted, unquoted.
43+
INCLUDE_RE = re.compile(r"""\{\{<\s*include\s+(?:"([^"]+)"|'([^']+)'|([^"'>\s]+))\s*>}}""")
44+
45+
46+
def run_git(args):
47+
"""Run a git command, returning stdout (stripped) or None on failure."""
48+
result = subprocess.run(
49+
["git", *args], capture_output=True, text=True, check=False
50+
)
51+
if result.returncode != 0:
52+
return None
53+
return result.stdout.strip()
54+
55+
56+
def changed_files(base_sha):
57+
"""Return the set of repo-relative paths changed between base and HEAD.
58+
59+
Uses the merge-base (``base...HEAD``) so files changed only on the base
60+
branch since the PR forked don't count as the PR's changes.
61+
"""
62+
merge_base = run_git(["merge-base", base_sha, "HEAD"]) or base_sha
63+
diff = run_git(["diff", "--name-only", f"{merge_base}...HEAD"])
64+
if diff is None:
65+
return None
66+
return {line for line in diff.splitlines() if line}
67+
68+
69+
def resolve_includes(qmd_path, _seen=None):
70+
"""Recursively collect the files a `.qmd` page depends on.
71+
72+
Returns absolute, resolved ``Path`` objects: the page itself plus every
73+
file it pulls in through `{{< include ... >}}`, resolved relative to the
74+
including file's directory. Cycles and missing targets are tolerated;
75+
missing/deleted targets are still recorded (so deleting an included
76+
subfile clears the parent's freezer) but not recursed into.
77+
"""
78+
if _seen is None:
79+
_seen = set()
80+
qmd_path = qmd_path.resolve()
81+
if qmd_path in _seen:
82+
return _seen
83+
_seen.add(qmd_path)
84+
if not qmd_path.is_file():
85+
return _seen
86+
87+
try:
88+
text = qmd_path.read_text(encoding="utf-8")
89+
except (OSError, UnicodeDecodeError):
90+
return _seen
91+
92+
for match in INCLUDE_RE.finditer(text):
93+
rel_path = match.group(1) or match.group(2) or match.group(3)
94+
included = (qmd_path.parent / rel_path).resolve()
95+
resolve_includes(included, _seen)
96+
return _seen
97+
98+
99+
def freeze_dir_for(rel_qmd, freeze_root):
100+
"""Map a repo-relative `.qmd` path to its `_freeze` directory.
101+
102+
Quarto mirrors the source path under the freezer without the extension,
103+
e.g. ``chapters/chapter1.qmd`` -> ``_freeze/chapters/chapter1``.
104+
"""
105+
return freeze_root / rel_qmd.with_suffix("")
106+
107+
108+
def main():
109+
repo_root = Path.cwd()
110+
freeze_root = Path(os.getenv("FREEZE_DIR", "_freeze"))
111+
112+
if not freeze_root.is_dir():
113+
print(f"No freezer at {freeze_root}; nothing to clear.")
114+
return
115+
116+
base_sha = os.getenv("BASE_SHA", "").strip()
117+
if not base_sha:
118+
print("BASE_SHA is unset; leaving the freezer to Quarto's freeze: auto.")
119+
return
120+
121+
changed = changed_files(base_sha)
122+
if changed is None:
123+
print(
124+
f"Could not diff against {base_sha}; leaving the freezer to "
125+
"Quarto's freeze: auto."
126+
)
127+
return
128+
129+
if not changed:
130+
print("No changed files relative to the base branch; freezer untouched.")
131+
return
132+
133+
changed_abs = {(repo_root / f).resolve() for f in changed}
134+
135+
cleared = []
136+
for qmd in sorted(repo_root.rglob("*.qmd")):
137+
# Skip pages living inside build-artifact trees.
138+
rel = qmd.relative_to(repo_root)
139+
if rel.parts and rel.parts[0] in {"_site", "_freeze", ".quarto"}:
140+
continue
141+
142+
deps = resolve_includes(qmd)
143+
if not (deps & changed_abs):
144+
continue
145+
146+
target = freeze_dir_for(rel, freeze_root)
147+
if not target.is_dir():
148+
continue
149+
150+
changed_deps = sorted(
151+
str(d.relative_to(repo_root)) for d in (deps & changed_abs)
152+
)
153+
print(f"Clearing freezer for {rel} (changed: {', '.join(changed_deps)})")
154+
# Remove the page's freezer tree so Quarto re-executes it.
155+
shutil.rmtree(target)
156+
cleared.append(str(rel))
157+
158+
if cleared:
159+
print(f"\nCleared freezer for {len(cleared)} page(s): {', '.join(cleared)}")
160+
else:
161+
print("No page's file or subfiles changed; freezer untouched.")
162+
163+
164+
if __name__ == "__main__":
165+
sys.exit(main())

.github/workflows/claude-code-review.yml

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -108,11 +108,13 @@ jobs:
108108
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
109109
# When claude.yml routes an `@claude review` comment here, it
110110
# dispatches this workflow via `gh workflow run`, so the run's
111-
# actor is github-actions[bot]. Without allowing that bot the
112-
# action aborts with "Workflow initiated by non-human actor" and
113-
# posts no review. The job `if:` already restricts dispatched runs
114-
# to same-repo, non-Dependabot PRs, so accepting the bot here is safe.
115-
allowed_bots: "github-actions[bot]"
111+
# actor is github-actions[bot]. A `claude` remote session pushing
112+
# a commit to a PR fires `synchronize` with the `claude` bot as
113+
# actor. Without allowing these bots the action aborts with
114+
# "Workflow initiated by non-human actor" and posts no review. The
115+
# job `if:` already restricts runs to same-repo, non-Dependabot
116+
# PRs, so accepting these bots here is safe.
117+
allowed_bots: "github-actions[bot],claude"
116118
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
117119
# `claude-code-plugins` is a branch name, not a version tag.
118120
# Intentionally unpinned so we pick up future improvements to the

.github/workflows/preview.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,19 @@ jobs:
7070
${{ runner.os }}-quarto-freezer-main-
7171
${{ runner.os }}-quarto-freezer-
7272
73+
# By default, clear the restored freezer only for pages whose own source
74+
# or included subfiles changed in this PR, so those pages re-execute
75+
# while everything else reuses its cached freeze. Quarto's `freeze: auto`
76+
# only re-runs a page when its *own* file hash changes, so without this a
77+
# page that pulls in a changed `{{< include >}}` subfile would render
78+
# stale. Skipped when the 'clear-freezer' label is set, since that path
79+
# already skips the restore above and rebuilds the whole freezer.
80+
- name: Clear freezer for changed pages
81+
if: ${{ !contains(github.event.pull_request.labels.*.name, 'clear-freezer') }}
82+
run: python3 .github/scripts/clear-changed-freezer.py
83+
env:
84+
BASE_SHA: ${{ github.event.pull_request.base.sha }}
85+
7386
- name: Render HTML
7487
uses: quarto-dev/quarto-actions/render@v2
7588
with:

0 commit comments

Comments
 (0)