Skip to content

Commit cf9994d

Browse files
fix(lib): always hash build context; record dirty flag (Greptile)
Address Greptile review on the build-provenance capture util: - Always compute working_tree_hash (drop the "skip on clean commit" path). A `git status` clean tree can still contain .gitignore'd-but-not- .dockerignore'd files the commit can't reproduce; an always-present content hash identifies the exact shipped bytes and closes that gap. - Guard the hash (_safe_working_tree_hash) so a permission error or filesystem race degrades to None instead of aborting the build — the module contract is that capture never raises into a build. - Record dirtiness as a first-class `dirty` flag (surfaced as `source_dirty` / `dirty`) rather than overloading hash-presence, matching Go's vcs.modified and Nix's dirtyRev. None outside a git work tree. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b1655e6 commit cf9994d

3 files changed

Lines changed: 57 additions & 44 deletions

File tree

src/agentex/lib/cli/handlers/agent_handlers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,7 @@ def prepare_cloud_build_context(
279279
(staged_root / _BUILD_INFO_FILENAME).write_text(json.dumps(provenance.build_info(), indent=2, sort_keys=True))
280280
logger.info(
281281
f"Build provenance: commit={provenance.commit} ref={provenance.ref} "
282-
f"clean_commit={provenance.is_clean_commit}"
282+
f"working_tree_hash={provenance.working_tree_hash}"
283283
)
284284

285285
# Compress the prepared context using the static zipped method

src/agentex/lib/utils/build_provenance.py

Lines changed: 33 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -28,40 +28,38 @@
2828

2929
@dataclass(frozen=True)
3030
class BuildProvenance:
31-
"""Source identity for one build. All fields degrade to ``None``.
31+
"""Source identity for one build; every field degrades to ``None``.
3232
33-
Exactly one identity anchors the build: a **clean committed tree** keys on
34-
``commit`` (``working_tree_hash`` is ``None``); anything else — a dirty tree
35-
or a non-git context, neither of which a commit can address — carries a
36-
``working_tree_hash`` instead. So a non-null hash means "no clean commit to
37-
point to," and ``is_clean_commit`` is the gate ``--require-clean`` checks.
33+
``working_tree_hash`` is the deterministic content hash of the build context
34+
and is always computed — it identifies the exact bytes that shipped,
35+
independent of git state. ``commit`` (+ ``ref`` / ``repo``) anchor those bytes
36+
to source, and ``dirty`` records whether the work tree had uncommitted changes
37+
at build time (``None`` outside a git work tree).
3838
"""
3939

4040
repo: Optional[str] = None
4141
commit: Optional[str] = None
4242
ref: Optional[str] = None
4343
subpath: Optional[str] = None
4444
working_tree_hash: Optional[str] = None
45+
dirty: Optional[bool] = None
4546
author_name: Optional[str] = None
4647
author_email: Optional[str] = None
4748
build_timestamp: Optional[str] = None
4849

49-
@property
50-
def is_clean_commit(self) -> bool:
51-
return self.commit is not None and self.working_tree_hash is None
52-
53-
def source_fields(self) -> dict[str, str]:
50+
def source_fields(self) -> dict[str, object]:
5451
"""The ``source_*`` form fields for the cloud-build upload (None omitted)."""
5552
fields = {
5653
"source_repo": self.repo,
5754
"source_commit": self.commit,
5855
"source_ref": self.ref,
5956
"source_subpath": self.subpath,
6057
"working_tree_hash": self.working_tree_hash,
58+
"source_dirty": self.dirty,
6159
}
6260
return {key: value for key, value in fields.items() if value is not None}
6361

64-
def build_info(self) -> dict[str, str]:
62+
def build_info(self) -> dict[str, object]:
6563
"""The ``build-info.json`` payload (runtime ``registration_metadata``).
6664
6765
Overlapping keys match the server's ``DeploymentHistory`` type
@@ -75,6 +73,7 @@ def build_info(self) -> dict[str, str]:
7573
"branch_name": self.ref,
7674
"subpath": self.subpath,
7775
"working_tree_hash": self.working_tree_hash,
76+
"dirty": self.dirty,
7877
"author_name": self.author_name,
7978
"author_email": self.author_email,
8079
"build_timestamp": self.build_timestamp,
@@ -167,6 +166,19 @@ def working_tree_hash(root: Path) -> str:
167166
return hashlib.sha256("\n".join(lines).encode("utf-8")).hexdigest()
168167

169168

169+
def _safe_working_tree_hash(root: Path) -> Optional[str]:
170+
"""``working_tree_hash`` that degrades to None — capture must never fail a build.
171+
172+
A permission error or filesystem race during the walk/stat/read would otherwise
173+
raise out of capture and abort the build before the archive is even created.
174+
"""
175+
try:
176+
return working_tree_hash(root)
177+
except Exception:
178+
logger.warning("build-provenance: content hash failed; omitting", exc_info=True)
179+
return None
180+
181+
170182
def capture_build_provenance(
171183
repo_path: Path, context_root: Path, content_root: Optional[Path] = None
172184
) -> BuildProvenance:
@@ -176,19 +188,18 @@ def capture_build_provenance(
176188
relative to the repo root (which agent, in a monorepo). ``content_root`` is
177189
the directory hashed — the *staged*, post-``.dockerignore`` tree that actually
178190
ships; it defaults to ``context_root`` when there is no separate staging dir.
179-
The content hash is computed unless a clean commit identifies the build (so:
180-
for a dirty tree or a non-git context, but not for a clean committed tree).
191+
``working_tree_hash`` is always computed; git coordinates anchor it to source
192+
when available.
181193
"""
182194
timestamp = datetime.now(timezone.utc).isoformat()
183195
hash_root = content_root if content_root is not None else context_root
196+
tree_hash = _safe_working_tree_hash(hash_root)
197+
184198
repo_root = _git(repo_path, "rev-parse", "--show-toplevel")
185199
if repo_root is None:
186-
# No git at all — the content hash is the only identity available.
187-
logger.info("build-provenance: %s is not a git work tree; hashing context", repo_path)
188-
return BuildProvenance(
189-
working_tree_hash=working_tree_hash(hash_root),
190-
build_timestamp=timestamp,
191-
)
200+
# No git — the content hash is the only identity available.
201+
logger.info("build-provenance: %s is not a git work tree; content hash only", repo_path)
202+
return BuildProvenance(working_tree_hash=tree_hash, build_timestamp=timestamp)
192203

193204
repo_root_path = Path(repo_root)
194205
commit = _git(repo_root_path, "rev-parse", "HEAD")
@@ -207,17 +218,16 @@ def capture_build_provenance(
207218
except ValueError:
208219
subpath = None
209220

210-
# Hash unless a clean commit identifies the build: dirty tree, or an unborn
211-
# HEAD with no commit yet, both fall back to the content hash.
221+
# `git status --porcelain` is empty (→ _git returns None) for a clean tree.
212222
dirty = _git(repo_root_path, "status", "--porcelain") is not None
213-
tree_hash = working_tree_hash(hash_root) if (dirty or commit is None) else None
214223

215224
return BuildProvenance(
216225
repo=remote,
217226
commit=commit,
218227
ref=ref,
219228
subpath=subpath,
220229
working_tree_hash=tree_hash,
230+
dirty=dirty,
221231
author_name=author_name,
222232
author_email=author_email,
223233
build_timestamp=timestamp,

tests/lib/test_build_provenance.py

Lines changed: 23 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -130,26 +130,13 @@ def test_capture_clean_tree(tmp_path: Path) -> None:
130130
assert prov.repo == "github.com/scaleapi/demo"
131131
assert prov.ref == "main"
132132
assert prov.commit is not None and len(prov.commit) == 40
133-
assert prov.working_tree_hash is None
134-
assert prov.is_clean_commit is True
133+
assert prov.working_tree_hash is not None # always computed
134+
assert prov.dirty is False
135135
assert prov.subpath is None
136136
assert prov.author_email == "dev@scale.com"
137137

138138

139-
def test_capture_dirty_tracked_modification(tmp_path: Path) -> None:
140-
repo = _init_repo(tmp_path / "repo")
141-
_write(repo, "main.py", "print(1)")
142-
_commit_all(repo)
143-
_write(repo, "main.py", "print(2)") # modify tracked, do not commit
144-
145-
prov = capture_build_provenance(repo, repo)
146-
147-
assert prov.is_clean_commit is False
148-
assert prov.working_tree_hash is not None
149-
assert prov.commit is not None # commit still recorded alongside the hash
150-
151-
152-
def test_capture_dirty_untracked_file_changes_hash(tmp_path: Path) -> None:
139+
def test_capture_untracked_file_changes_hash(tmp_path: Path) -> None:
153140
repo = _init_repo(tmp_path / "repo")
154141
_write(repo, "main.py", "print(1)")
155142
_commit_all(repo)
@@ -159,7 +146,7 @@ def test_capture_dirty_untracked_file_changes_hash(tmp_path: Path) -> None:
159146

160147
# The stale-code guard: an untracked file is part of the build context, so it
161148
# must move the hash (a `git diff` of tracked files alone would miss it).
162-
assert prov.is_clean_commit is False
149+
assert prov.dirty is True
163150
assert prov.working_tree_hash == working_tree_hash(repo)
164151
assert working_tree_hash(repo) != _hash_without(repo, "scratch.py")
165152

@@ -214,7 +201,7 @@ def test_capture_no_remote(tmp_path: Path) -> None:
214201

215202
assert prov.repo is None
216203
assert prov.commit is not None
217-
assert prov.is_clean_commit is True # no remote, but a clean commit still anchors it
204+
assert prov.working_tree_hash is not None # always computed
218205

219206

220207
def test_capture_non_git_dir(tmp_path: Path) -> None:
@@ -226,12 +213,28 @@ def test_capture_non_git_dir(tmp_path: Path) -> None:
226213
assert prov.repo is None
227214
assert prov.commit is None
228215
assert prov.ref is None
229-
# No commit to point to → the content hash is the identity.
216+
# No commit → the content hash is the identity; dirtiness is undefined (no VCS).
230217
assert prov.working_tree_hash == working_tree_hash(plain)
231-
assert prov.is_clean_commit is False
218+
assert prov.dirty is None
232219
assert prov.build_timestamp is not None
233220

234221

222+
def test_capture_never_raises_when_hash_fails(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
223+
import agentex.lib.utils.build_provenance as bp
224+
225+
plain = tmp_path / "plain" # non-git → would hash, which we force to fail
226+
_write(plain, "main.py", "print(1)")
227+
228+
def _boom(_root: Path) -> str:
229+
raise OSError("permission denied")
230+
231+
monkeypatch.setattr(bp, "working_tree_hash", _boom)
232+
233+
prov = bp.capture_build_provenance(plain, plain) # must not raise
234+
235+
assert prov.working_tree_hash is None
236+
237+
235238
def test_capture_monorepo_subpath(tmp_path: Path) -> None:
236239
repo = _init_repo(tmp_path / "repo")
237240
_write(repo, "agents/foo/main.py", "print(1)")

0 commit comments

Comments
 (0)