Coverage for core/langgraph/tools/git_checkout.py: 97%

139 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-10-04 15:34 +0000

1"""Per-tool module for the `git-checkout` deterministic builtin. 

2 

3Owns the tool's model-facing definition, its instruction surface (none), the 

4git-checkout-only helpers (env requirements, project reset, the streaming and 

5capturing git runners, branch derivation/resolution) and the checkout 

6execution itself (ADR-0012/ADR-0018). The definition and the execution here 

7are the single source; the legacy builtin site delegates to the pure 

8`_git_checkout` handler. 

9""" 

10from __future__ import annotations 

11 

12import json 

13import os 

14import shutil 

15import subprocess 

16from typing import Any 

17from urllib.parse import urlparse, urlunparse 

18 

19from .contract import ToolContext, ToolResult 

20 

21try: 

22 from exec_runner import slugify 

23except ImportError: # pragma: no cover — main.py run (qualified imports) 

24 from langgraph.exec_runner import slugify 

25 

26GIT_CHECKOUT_TOOL_NAME = "git-checkout" 

27 

28GIT_CHECKOUT_DEFINITION: dict[str, Any] = { 

29 "type": "function", 

30 "function": { 

31 "name": GIT_CHECKOUT_TOOL_NAME, 

32 "description": ( 

33 "Prepare the working tree with `git-checkout` before any step that " 

34 "reads or writes repository files. It clones the target repository " 

35 "for the active run profile and exposes the checkout location and " 

36 "branch facts in state; do not clone the repository yourself." 

37 ), 

38 "parameters": { 

39 "type": "object", 

40 "properties": {}, 

41 "additionalProperties": False, 

42 }, 

43 }, 

44} 

45 

46_PATCH_KEYS = ( 

47 "checkout_done", 

48 "project_dir", 

49 "ticket_branch", 

50 "ticket_base_branch", 

51 "pr_diff", 

52 "changed_files", 

53) 

54 

55 

56def _hard_failure_type() -> type[Exception]: 

57 """The runner's hard-failure type, resolved without an import cycle.""" 

58 try: 

59 from runner import RunHardFailure 

60 except ImportError: # pragma: no cover — main.py run (qualified imports) 

61 from langgraph.runner import RunHardFailure 

62 return RunHardFailure 

63 

64 

65def _hard_failure(code: str, message: str, details: dict | None = None) -> Exception: 

66 return _hard_failure_type()(code, message, details) 

67 

68 

69def _git_argv(*args: str) -> list[str]: 

70 return ["git", "-c", "safe.directory=*", *args] 

71 

72 

73def _git_failure(args: tuple[str, ...], exc: subprocess.CalledProcessError) -> Exception: 

74 detail = (exc.stderr or exc.stdout or "").strip() 

75 message = f"git-checkout: 'git {' '.join(args)}' failed (exit {exc.returncode})" 

76 return _hard_failure("EXECUTION_ERROR", f"{message}: {detail}" if detail else message) 

77 

78 

79def _git_spawn_failure(args: tuple[str, ...], exc: OSError) -> Exception: 

80 return _hard_failure( 

81 "EXECUTION_ERROR", 

82 f"git-checkout: 'git {' '.join(args)}' could not be started: {exc}", 

83 ) 

84 

85 

86def _run_git(args: tuple[str, ...], cwd: str | None = None) -> None: 

87 """Run a git command with its output streaming to the run log. 

88 

89 Clone/checkout/fetch output must stay observable, so git inherits the 

90 parent's stdout/stderr instead of being captured. A subprocess failure 

91 (nonzero exit or spawn failure, e.g. a missing git binary) becomes a 

92 typed hard failure — never a raw exception. 

93 """ 

94 try: 

95 subprocess.run(_git_argv(*args), cwd=cwd, check=True) 

96 except subprocess.CalledProcessError as exc: 

97 raise _git_failure(args, exc) from exc 

98 except OSError as exc: 

99 raise _git_spawn_failure(args, exc) from exc 

100 

101 

102def _capture_git( 

103 args: tuple[str, ...], cwd: str | None = None 

104) -> subprocess.CompletedProcess: 

105 """Run a git command whose stdout is parsed by the caller. 

106 

107 Capture is genuinely required for ls-remote/diff (the output is 

108 consumed), unlike the streaming clone/checkout path in `_run_git`. 

109 """ 

110 try: 

111 return subprocess.run( 

112 _git_argv(*args), 

113 cwd=cwd, 

114 capture_output=True, 

115 text=True, 

116 check=True, 

117 ) 

118 except subprocess.CalledProcessError as exc: 

119 raise _git_failure(args, exc) from exc 

120 except OSError as exc: 

121 raise _git_spawn_failure(args, exc) from exc 

122 

123 

124def _reset_project_dir(project_dir: str) -> None: 

125 if not os.path.exists(project_dir): 

126 return 

127 try: 

128 with os.scandir(project_dir) as entries: 

129 for entry in entries: 

130 if entry.is_symlink() or not entry.is_dir(): 

131 os.unlink(entry.path) 

132 else: 

133 shutil.rmtree(entry.path) 

134 except OSError as exc: 

135 raise _hard_failure( 

136 "EXECUTION_ERROR", 

137 f"git-checkout: failed to reset {project_dir!r}: {exc}", 

138 ) from exc 

139 

140 

141def _require_env(name: str) -> str: 

142 value = os.environ.get(name) 

143 if not value: 

144 raise _hard_failure( 

145 "VALIDATION_ERROR", 

146 f"git-checkout: required env var {name!r} is not set", 

147 ) 

148 return value 

149 

150 

151def _resolve_default_branch(project_dir: str) -> str: 

152 """Resolve the repo's remote default branch (repo fact, not a fallback). 

153 

154 TICKET profile with an empty BASE_BRANCH: the base branch is the remote 

155 default reported by `git ls-remote --symref origin HEAD` — never a 

156 hardcoded "main" assumption (ADR-0012, contract BASE_BRANCH row). 

157 """ 

158 proc = _capture_git(("ls-remote", "--symref", "origin", "HEAD"), cwd=project_dir) 

159 for line in proc.stdout.splitlines(): 

160 if line.startswith("ref: "): 

161 # Format: "ref: refs/heads/main\tHEAD" 

162 parts = line.split("\t") 

163 ref = parts[0][len("ref: "):].strip() 

164 if ref.startswith("refs/heads/"): 

165 return ref[len("refs/heads/"):] 

166 raise _hard_failure( 

167 "EXECUTION_ERROR", 

168 "git-checkout: could not determine the remote default branch from " 

169 "'git ls-remote --symref origin HEAD' (no ref: refs/heads/... HEAD line)", 

170 ) 

171 

172 

173def _ticket_branch(issue_key: str, title: str) -> str: 

174 """Deterministic per-run feature branch for the TICKET profile. 

175 

176 Format (locked): ticket/<issue_key>-<slug(title)>; slugify is the 

177 ADR-0018 slug. Retries and continuation runs reuse the same branch: 

178 git-checkout resumes an existing remote branch instead of creating a 

179 new one per run, and the push is plain — never a forced push. 

180 """ 

181 return f"ticket/{issue_key}-{slugify(title)}" 

182 

183 

184def _remote_branch_exists(remote_url: str, branch: str) -> bool: 

185 """True when the remote already has the branch (TICKET resume path). 

186 

187 `git ls-remote --heads <url> refs/heads/<branch>` runs BEFORE the 

188 clone — a non-empty stdout line means the branch exists and the 

189 checkout resumes it instead of creating a new one (continuation runs 

190 continue the SAME branch and PR). Failure → hard error (fail-fast, 

191 ADR-0012). 

192 """ 

193 proc = _capture_git( 

194 ("ls-remote", "--heads", remote_url, f"refs/heads/{branch}"), 

195 ) 

196 return bool(proc.stdout.strip()) 

197 

198 

199def _join_pr_diff(patches: list[str]) -> str: 

200 return "\n".join(p for p in patches if p) 

201 

202 

203def _git_checkout(step: dict, state: dict) -> dict: 

204 url = _require_env("GIT_REPO_URL") 

205 token = _require_env("GIT_TOKEN") 

206 run_profile = _require_env("RUN_PROFILE") 

207 

208 if run_profile == "PR": 

209 branch = _require_env("PR_HEAD_BRANCH") 

210 elif run_profile == "BRANCH": 

211 branch = _require_env("BASE_BRANCH") 

212 elif run_profile != "TICKET": 

213 raise _hard_failure( 

214 "VALIDATION_ERROR", 

215 f"git-checkout: unsupported RUN_PROFILE {run_profile!r} " 

216 "(expected 'PR', 'BRANCH', or 'TICKET')", 

217 ) 

218 

219 parsed = urlparse(url) 

220 host = parsed.hostname or "" 

221 new_netloc = f"x-access-token:{token}@{host}" 

222 url = urlunparse(parsed._replace(netloc=new_netloc)) 

223 

224 project_dir = "/project" 

225 _reset_project_dir(project_dir) 

226 

227 if run_profile == "TICKET": 

228 base_branch = os.environ.get("BASE_BRANCH") or "" 

229 issue_key = _require_env("ISSUE_KEY") 

230 title = _require_env("PR_TITLE") 

231 feature_branch = _ticket_branch(issue_key, title) 

232 

233 if _remote_branch_exists(url, feature_branch): 

234 _run_git( 

235 ("clone", "--depth", "1", "--branch", feature_branch, url, project_dir), 

236 ) 

237 if not base_branch: 

238 # Resume + empty BASE_BRANCH: the PR base is the remote default. 

239 base_branch = _resolve_default_branch(project_dir) 

240 else: 

241 if base_branch: 

242 _run_git( 

243 ("clone", "--depth", "1", "--branch", base_branch, url, project_dir), 

244 ) 

245 else: 

246 # Empty BASE_BRANCH → clone the default branch, then resolve the 

247 # default branch name as a repo fact (PR base field + state). 

248 _run_git( 

249 ("clone", "--depth", "1", url, project_dir), 

250 ) 

251 base_branch = _resolve_default_branch(project_dir) 

252 _run_git(("checkout", "-b", feature_branch), cwd=project_dir) 

253 result = {**state, "checkout_done": True, "project_dir": project_dir, 

254 "ticket_branch": feature_branch, "ticket_base_branch": base_branch} 

255 return result 

256 

257 _run_git(("clone", "--depth", "1", "--branch", branch, url, project_dir)) 

258 

259 result = {**state, "checkout_done": True, "project_dir": project_dir} 

260 

261 if run_profile == "PR": 

262 base_branch = _require_env("PR_BASE_BRANCH") 

263 diff_base = f"origin/{base_branch}" 

264 

265 _run_git( 

266 ("fetch", "--depth", "1", "origin", f"{base_branch}:refs/remotes/origin/{base_branch}"), 

267 cwd=project_dir, 

268 ) 

269 changed = _capture_git(("diff", "--name-only", f"{diff_base}..HEAD"), cwd=project_dir) 

270 diff_out = _capture_git(("diff", f"{diff_base}..HEAD"), cwd=project_dir) 

271 changed_files = [line for line in changed.stdout.splitlines() if line.strip()] 

272 pr_diff = _join_pr_diff([diff_out.stdout]) 

273 result = {**result, "pr_diff": pr_diff, "changed_files": changed_files} 

274 

275 return result 

276 

277 

278def _state_patch(new_state: dict) -> dict[str, Any]: 

279 return {key: new_state[key] for key in _PATCH_KEYS if key in new_state} 

280 

281 

282def _success_message(patch: dict[str, Any]) -> str: 

283 return json.dumps( 

284 {"name": GIT_CHECKOUT_TOOL_NAME, "ok": True, "output": patch}, 

285 ensure_ascii=False, 

286 ) 

287 

288 

289def _failure_message(error: str) -> str: 

290 return json.dumps( 

291 {"name": GIT_CHECKOUT_TOOL_NAME, "ok": False, "error": error}, 

292 ensure_ascii=False, 

293 ) 

294 

295 

296class _GitCheckoutTool: 

297 name = GIT_CHECKOUT_TOOL_NAME 

298 

299 def definition(self) -> dict[str, Any]: 

300 return GIT_CHECKOUT_DEFINITION 

301 

302 def instruction(self) -> str | None: 

303 return None 

304 

305 def params(self) -> list[dict[str, Any]] | None: 

306 return None 

307 

308 def execute(self, context: ToolContext) -> ToolResult: 

309 try: 

310 new_state = _git_checkout(context.step or {}, context.state or {}) 

311 except _hard_failure_type() as exc: 

312 return ToolResult( 

313 ok=False, 

314 content=_failure_message(exc.message), 

315 error=exc.message, 

316 ) 

317 patch = _state_patch(new_state) 

318 return ToolResult( 

319 ok=True, 

320 content=_success_message(patch), 

321 state_patch=patch, 

322 ) 

323 

324 

325TOOL = _GitCheckoutTool()