Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion autohands/build_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,14 +263,40 @@ def run_capped(args, timeout, check=False, stdout=None, stderr=None,
return subprocess.CompletedProcess(args, proc.returncode, output, errs)


NOTEBOOK_CONVERTER = "ipynb-py-convert"


def assistant_behind_origin(root):
"""
How many commits the assistant checkout at ``root`` is behind its local
``origin/main`` ref, or None when that cannot be told (not a git checkout,
no such ref). Reads local refs only — it never fetches.
"""
try:
result = subprocess.run(
["git", "-C", str(root), "rev-list", "--count", "HEAD..origin/main"],
capture_output=True,
text=True,
timeout=30,
)
except (OSError, subprocess.SubprocessError):
return None
if result.returncode != 0:
return None
try:
return int(result.stdout.strip())
except ValueError:
return None


def py_to_notebook(filename: Path):
subprocess.run(
["python3", f"{BUILD_PATH}/add_notebook_quotes.py", filename, "temp.py"],
check=True,
)
new_filename = filename.with_suffix(".ipynb")
subprocess.run(
["ipynb-py-convert", "temp.py", new_filename],
[NOTEBOOK_CONVERTER, "temp.py", new_filename],
check=True,
)
os.remove("temp.py")
Expand Down
22 changes: 22 additions & 0 deletions autohands/generate.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,32 @@ def copy_to_notebooks(source):
f"{sorted(build_util.COLAB_PROJECTS)}. Nothing was modified."
)

# The converter must be on PATH before anything is deleted: the root
# `start_here*.ipynb` is removed below before `build_util.py_to_notebook`
# shells out to `ipynb-py-convert`, so a missing converter used to fail
# mid-run with start_here.ipynb deleted and a stray temp.py left behind
# (PyAutoHands#330).
if shutil.which(build_util.NOTEBOOK_CONVERTER) is None:
sys.exit(
f"generate.py: '{build_util.NOTEBOOK_CONVERTER}' is not on PATH — install it "
f"in this environment (pip install ipynb-py-convert). Nothing was modified."
)

# The assistant whose mapping stamps each notebook's Colab Gemini
# instructions (#324). Resolved before the rmtree so a mapping that cannot
# load stops the run with the committed notebooks intact.
assistant_root = build_util.resolve_assistant_root(project, args.assistant_root)
# A stale assistant checkout silently rewrites every notebook's Gemini
# context (239 spurious notebook diffs on 2026-11-04), so say so loudly.
if assistant_root is not None:
behind = build_util.assistant_behind_origin(assistant_root)
if behind:
print(
f"WARNING: assistant checkout {assistant_root} is {behind} commit(s) behind "
f"origin/main — the Gemini instructions it stamps may be stale. "
f"Pull it before generating.",
file=sys.stderr,
)
gemini_contexts = (
build_util.load_gemini_contexts(assistant_root) if assistant_root else None
)
Expand Down
81 changes: 81 additions & 0 deletions tests/test_generate_validates_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,84 @@ def test_known_project_passes_validation_and_regenerates(tmp_path):
assert notebook.exists()
assert "Intro prose." in notebook.read_text()
assert list(workspace.glob("scripts/**/*.ipynb")) == []


def _path_without_converter(tmp_path: Path) -> str:
"""A PATH holding git but no ipynb-py-convert (PyAutoHands#330)."""
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
git = subprocess.run(["which", "git"], capture_output=True, text=True, check=True)
(bin_dir / "git").symlink_to(git.stdout.strip())
return str(bin_dir)


def test_missing_converter_exits_nonzero_without_touching_anything(tmp_path):
workspace = _make_workspace(tmp_path / "ws")
# A root start_here pair: generate removes start_here*.ipynb before it
# converts, which is what a missing converter used to strand.
(workspace / "start_here.py").write_text('"""\nStart\n=====\n"""\n\nvalue = 1\n')
(workspace / "start_here.ipynb").write_text('{"cells": [], "nbformat": 4}\n')
subprocess.run(["git", "add", "-A"], cwd=workspace, check=True)
subprocess.run(
["git", "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "start_here"],
cwd=workspace,
check=True,
)

env = dict(
os.environ,
PYTHONPATH=str(AUTOHANDS_DIR),
PATH=_path_without_converter(tmp_path),
)
result = subprocess.run(
[sys.executable, str(GENERATE), "autolens"],
cwd=workspace,
capture_output=True,
text=True,
env=env,
)

assert result.returncode != 0
assert "ipynb-py-convert" in result.stderr
assert "Nothing was modified" in result.stderr

status = subprocess.run(
["git", "status", "--porcelain"],
cwd=workspace,
capture_output=True,
text=True,
check=True,
)
assert status.stdout == ""
assert (workspace / "start_here.ipynb").exists()
assert not (workspace / "temp.py").exists()


def _git(cwd, *args):
subprocess.run(
["git", "-c", "user.email=t@t", "-c", "user.name=t", *args],
cwd=cwd,
check=True,
capture_output=True,
)


def test_assistant_behind_origin_counts_local_lag(tmp_path):
origin = tmp_path / "origin"
origin.mkdir()
_git(origin, "init", "-q", "-b", "main")
_git(origin, "commit", "-q", "--allow-empty", "-m", "one")
clone = tmp_path / "clone"
_git(tmp_path, "clone", "-q", str(origin), str(clone))

assert build_util.assistant_behind_origin(clone) == 0

_git(origin, "commit", "-q", "--allow-empty", "-m", "two")
_git(origin, "commit", "-q", "--allow-empty", "-m", "three")
_git(clone, "fetch", "-q")

assert build_util.assistant_behind_origin(clone) == 2


def test_assistant_behind_origin_is_none_outside_git(tmp_path):
assert build_util.assistant_behind_origin(tmp_path) is None
Loading