diff --git a/autohands/build_util.py b/autohands/build_util.py index 83f07d5..07b5501 100644 --- a/autohands/build_util.py +++ b/autohands/build_util.py @@ -263,6 +263,32 @@ 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"], @@ -270,7 +296,7 @@ def py_to_notebook(filename: Path): ) 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") diff --git a/autohands/generate.py b/autohands/generate.py index b1d57ea..938baf1 100644 --- a/autohands/generate.py +++ b/autohands/generate.py @@ -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 ) diff --git a/tests/test_generate_validates_project.py b/tests/test_generate_validates_project.py index d765bf7..37ff0c3 100644 --- a/tests/test_generate_validates_project.py +++ b/tests/test_generate_validates_project.py @@ -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