post_land_sync.py file-level

at main · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 💥 blast risk
sha256:6 fix(ISR): default require_independent_second_reviewer to require Opera… · aaronrene · Sep 2, 2026
1 """Post-land main sync — optional ff-only local sync after ``ok pr-land`` (§PLS.4–§PLS.5).
2
3 Runs only after a **successful** authorized merge outcome (``merged: true``,
4 pre-sync exit ``0``) when ``close_ritual.post_land_sync.enabled`` is ``true``.
5
6 Frozen sequence (docs/archive/phases/PHASE-PLS-POST-LAND-MAIN-SYNC.md §PLS.4.2):
7
8 S1 git fetch <remote>
9 S2 git status --porcelain (full tree)
10 S3 dirty + require_clean_worktree → skip with warn (never clobber)
11 S4 clean + HEAD != main_branch → git checkout <main_branch>
12 S5 git pull --ff-only <remote> <main_branch>
13 S6 emit editor-buffer note
14
15 Never: ``--force``, ``reset --hard``, ``clean -fd``, stash-pop defaults,
16 non-ff merges, ``gh pr merge``, pushes, or Muse bridge export.
17 ``muse-only`` regimes short-circuit with ``regime_skipped`` and zero git argv.
18 """
19
20 from __future__ import annotations
21
22 import subprocess
23 from dataclasses import asdict, dataclass, field
24 from pathlib import Path
25 from typing import Any, Callable
26
27 STATUS_DISABLED = "disabled"
28 STATUS_REGIME_SKIPPED = "regime_skipped"
29 STATUS_SKIPPED_DIRTY = "skipped_dirty"
30 STATUS_SYNCED = "synced"
31 STATUS_FAILED = "failed"
32 STATUS_NOT_APPLICABLE = "not_applicable"
33
34 #: Normative operator note after a successful ff-only sync (§PLS.4.4).
35 EDITOR_BUFFER_NOTE = (
36 "post_land_sync: editor buffers may be stale — reload governance docs from disk; "
37 "never overwrite disk with old tab content"
38 )
39
40 _DIRTY_SUMMARY_LIMIT = 10
41
42 GitRunner = Callable[[list[str]], "subprocess.CompletedProcess[str]"]
43
44
45 @dataclass
46 class PostLandSyncReport:
47 """Always-present ``post_land_sync`` object on ``PrLandResult`` (§PLS.6.3)."""
48
49 status: str
50 remote: str = ""
51 main_branch: str = ""
52 messages: list[str] = field(default_factory=list)
53
54 def to_dict(self) -> dict[str, Any]:
55 return asdict(self)
56
57
58 def disabled_report() -> PostLandSyncReport:
59 """Report for sync disabled/omitted (also for ``run_pr_land`` without config)."""
60 return PostLandSyncReport(status=STATUS_DISABLED)
61
62
63 def not_applicable_report() -> PostLandSyncReport:
64 """Report when sync is enabled but not triggered (no successful merge / dry_run)."""
65 return PostLandSyncReport(status=STATUS_NOT_APPLICABLE)
66
67
68 def _make_default_runner(repo_root: Path) -> GitRunner:
69 def _run(cmd: list[str]) -> subprocess.CompletedProcess[str]:
70 return subprocess.run(
71 cmd,
72 cwd=str(repo_root),
73 capture_output=True,
74 text=True,
75 )
76
77 return _run
78
79
80 def _unsafe_ref_component(value: str) -> bool:
81 """Fail closed on option-injection / control chars in remote or branch names."""
82 if not value or value.startswith("-"):
83 return True
84 return any(ch.isspace() or ord(ch) < 32 for ch in value)
85
86
87 def _summarize_dirty(porcelain: str) -> list[str]:
88 paths = [line[3:].strip() for line in porcelain.splitlines() if line.strip()]
89 shown = paths[:_DIRTY_SUMMARY_LIMIT]
90 extra = len(paths) - len(shown)
91 lines = [f" dirty: {p}" for p in shown]
92 if extra > 0:
93 lines.append(f" … and {extra} more dirty path(s)")
94 return lines
95
96
97 def run_post_land_sync(
98 *,
99 repo_root: Path | None,
100 regime: str,
101 remote: str,
102 main_branch: str,
103 require_clean_worktree: bool = True,
104 git_runner: GitRunner | None = None,
105 emit: Callable[[str], None] | None = None,
106 ) -> PostLandSyncReport:
107 """Execute the frozen §PLS.4.2 sequence; never clobber a dirty tree.
108
109 Returns a report with ``status`` in ``regime_skipped | skipped_dirty |
110 synced | failed``. Callers map ``failed`` to exit ``36`` (§PLS.6.2).
111 """
112 messages: list[str] = []
113
114 def _emit(line: str) -> None:
115 messages.append(line)
116 if emit:
117 emit(line)
118
119 if regime == "muse-only":
120 _emit("post_land_sync: muse-only regime — git post-land sync does not apply")
121 return PostLandSyncReport(status=STATUS_REGIME_SKIPPED, messages=messages)
122
123 if repo_root is None:
124 _emit("post_land_sync: FAILED — repo_root required when sync is enabled")
125 return PostLandSyncReport(
126 status=STATUS_FAILED, remote=remote, main_branch=main_branch, messages=messages
127 )
128
129 if _unsafe_ref_component(remote) or _unsafe_ref_component(main_branch):
130 _emit("post_land_sync: FAILED — unsafe remote/main_branch value (fail closed)")
131 return PostLandSyncReport(
132 status=STATUS_FAILED, remote=remote, main_branch=main_branch, messages=messages
133 )
134
135 run = git_runner or _make_default_runner(repo_root)
136
137 # S1 — fetch
138 fetched = run(["git", "fetch", remote])
139 if fetched.returncode != 0:
140 _emit(
141 "post_land_sync: FAILED — git fetch "
142 f"{remote}: {(fetched.stderr or fetched.stdout or '').strip()}"
143 )
144 return PostLandSyncReport(
145 status=STATUS_FAILED, remote=remote, main_branch=main_branch, messages=messages
146 )
147
148 # S2 — dirty state (full tree)
149 status = run(["git", "status", "--porcelain"])
150 if status.returncode != 0:
151 _emit(
152 "post_land_sync: FAILED — git status unreadable: "
153 f"{(status.stderr or status.stdout or '').strip()}"
154 )
155 return PostLandSyncReport(
156 status=STATUS_FAILED, remote=remote, main_branch=main_branch, messages=messages
157 )
158
159 porcelain = status.stdout or ""
160 if porcelain.strip() and require_clean_worktree:
161 # S3 — dirty skip: never stash/reset/checkout/pull
162 _emit(
163 "post_land_sync: WARN — working tree dirty; skipping local main sync "
164 "(never clobbers). Clean the tree, then pull manually or re-run ok pr-land."
165 )
166 for line in _summarize_dirty(porcelain):
167 _emit(line)
168 return PostLandSyncReport(
169 status=STATUS_SKIPPED_DIRTY,
170 remote=remote,
171 main_branch=main_branch,
172 messages=messages,
173 )
174
175 # S4 — clean tree not on main → checkout main (allowed only when clean)
176 head = run(["git", "rev-parse", "--abbrev-ref", "HEAD"])
177 if head.returncode != 0:
178 _emit(
179 "post_land_sync: FAILED — cannot read current branch: "
180 f"{(head.stderr or head.stdout or '').strip()}"
181 )
182 return PostLandSyncReport(
183 status=STATUS_FAILED, remote=remote, main_branch=main_branch, messages=messages
184 )
185 current_branch = (head.stdout or "").strip()
186 if current_branch != main_branch:
187 checkout = run(["git", "checkout", main_branch])
188 if checkout.returncode != 0:
189 _emit(
190 f"post_land_sync: FAILED — git checkout {main_branch}: "
191 f"{(checkout.stderr or checkout.stdout or '').strip()}"
192 )
193 return PostLandSyncReport(
194 status=STATUS_FAILED, remote=remote, main_branch=main_branch, messages=messages
195 )
196 _emit(f"post_land_sync: checked out {main_branch} (was {current_branch or 'detached'})")
197
198 # S5 — ff-only pull
199 pulled = run(["git", "pull", "--ff-only", remote, main_branch])
200 if pulled.returncode != 0:
201 _emit(
202 f"post_land_sync: FAILED — git pull --ff-only {remote} {main_branch}: "
203 f"{(pulled.stderr or pulled.stdout or '').strip()}"
204 )
205 return PostLandSyncReport(
206 status=STATUS_FAILED, remote=remote, main_branch=main_branch, messages=messages
207 )
208
209 # S6 — success + editor-buffer note
210 _emit(f"post_land_sync: local {main_branch} fast-forwarded to {remote}/{main_branch}")
211 _emit(EDITOR_BUFFER_NOTE)
212 return PostLandSyncReport(
213 status=STATUS_SYNCED, remote=remote, main_branch=main_branch, messages=messages
214 )