diff --git a/hermes_cli/_update_takeover.py b/hermes_cli/_update_takeover.py index e18b78178b..72015741a6 100644 --- a/hermes_cli/_update_takeover.py +++ b/hermes_cli/_update_takeover.py @@ -16,6 +16,9 @@ def prepare(request: dict) -> tuple[Path, dict[str, str]]: from hermes_cli.update_stage import ensure_panel, publish_stage ensure_panel(root) + # An older updater hands off here instead of update_completion._prepare. + from hermes_cli.gitlock import convert_treeless_checkout_first + convert_treeless_checkout_first(root) publish_stage("Updating Python dependencies (PM)") from pm import receipt from pm.client import ensure_tools_for_sync, sync_venv, venv_is_current diff --git a/hermes_cli/git_pack_tidy.py b/hermes_cli/git_pack_tidy.py index 3a5d0aed8f..dc056736df 100644 --- a/hermes_cli/git_pack_tidy.py +++ b/hermes_cli/git_pack_tidy.py @@ -125,6 +125,12 @@ def _pinned(pack: Path, cutoff: float) -> bool: return True +def _unlink(part: Path) -> None: + if os.name == "nt": + os.chmod(part, 0o666) # git writes packs read-only; Windows refuses to unlink those + part.unlink() + + def _drop_midx(pack_dir: Path) -> None: """A multi-pack-index names the packs it covers; dropping it before any pack goes means no crash can leave one naming a deleted pack. git rebuilds it on its own maintenance. @@ -136,9 +142,7 @@ def _drop_midx(pack_dir: Path) -> None: (layers / "multi-pack-index-chain").unlink(missing_ok=True) for layer in layers.glob("*"): with contextlib.suppress(OSError): # a layer a reader still maps goes on a later run - if os.name == "nt": - os.chmod(layer, 0o666) # git writes layers read-only; Windows refuses to unlink those - layer.unlink() + _unlink(layer) with contextlib.suppress(OSError): layers.rmdir() @@ -154,9 +158,7 @@ def _remove_pack(pack: Path) -> int: part = pack.with_suffix(suffix) try: size = part.stat().st_size - if os.name == "nt": - os.chmod(part, 0o666) # git writes packs read-only; Windows refuses to unlink those - part.unlink() + _unlink(part) freed += size except FileNotFoundError: continue @@ -164,8 +166,16 @@ def _remove_pack(pack: Path) -> int: def _sweep_remnants(pack_dir: Path) -> None: - """Finish deletions an earlier run could not complete: pack files whose payload is already gone.""" + """Finish deletions an earlier run could not complete: pack files whose payload is already gone, + and ``tmp_*`` transfer files a killed fetch left (a disk-filling fetch storm leaves gigabytes; + git only prunes them after two weeks). A live transfer keeps writing, so the age guard spares it.""" cutoff = time.time() - _MIN_PACK_AGE_SECONDS + for part in pack_dir.glob("tmp_*"): + try: + if part.stat().st_mtime < cutoff: + _unlink(part) + except OSError: + logger.debug("transfer temp %s still in use", part.name, exc_info=True) for part in pack_dir.glob("pack-*.*"): try: if part.suffix in _PACK_SUFFIXES[1:] and not part.with_suffix(".pack").exists() \ diff --git a/hermes_cli/gitlock.py b/hermes_cli/gitlock.py index c46818d7b5..1396a8dc6e 100644 --- a/hermes_cli/gitlock.py +++ b/hermes_cli/gitlock.py @@ -652,6 +652,26 @@ def convert_treeless_checkout(repo_root: Path, **run_kwargs) -> bool: return True +def convert_treeless_checkout_first(repo_root: Path) -> None: + """Run the one-time conversion as the update's first new-code step. Never raises. + + Both update hand-offs (``update_completion._prepare`` and an older updater's + ``_update_takeover.prepare``) call this before minutes of dependency work: a Desktop built before + the fix keeps walking history during that window, and on a treeless checkout every walk + downloads trees again (#129514: 434 GB, disk full mid-update). Runs in the dependency-free + bootstrap interpreter, so only the prompt is disabled here; a credential helper still works. + """ + env = {**os.environ, "GIT_TERMINAL_PROMPT": "0", "GCM_INTERACTIVE": "Never"} + try: + if convert_treeless_checkout(repo_root, env=env): + print(" ✓ Fetched this checkout's directory history once; updates stop re-downloading it", + flush=True) + except (OSError, subprocess.SubprocessError) as exc: + detail = (getattr(exc, "stderr", None) or str(exc)).strip().splitlines()[-1:] or [type(exc).__name__] + print(f" ⚠ Could not fetch this checkout's directory history ({detail[0]}); retrying next update", + flush=True) + + def _git_version(**run_kwargs) -> tuple: out = subprocess.run(["git", "--version"], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30, **run_kwargs).stdout diff --git a/hermes_cli/update_cmd_maint.py b/hermes_cli/update_cmd_maint.py index 3ec8d6fa93..193ac25e27 100644 --- a/hermes_cli/update_cmd_maint.py +++ b/hermes_cli/update_cmd_maint.py @@ -1004,15 +1004,6 @@ def _run_post_update_maintenance( # Both shallow history and missing tags can hide the release identity. # Refresh them before the completion line and install stamp read it. - try: - from hermes_cli.gitlock import convert_treeless_checkout - from hermes_cli.update_cmd import _no_prompt_git_kwargs - if convert_treeless_checkout(Path(_m().PROJECT_ROOT), **_no_prompt_git_kwargs()): - print(" ✓ Fetched this checkout's directory history once; updates stop re-downloading it") - except (OSError, subprocess.SubprocessError) as exc: - detail = (getattr(exc, "stderr", None) or str(exc)).strip().splitlines()[-1:] or [type(exc).__name__] - print(f" ⚠ Could not fetch this checkout's directory history ({detail[0]}); retrying next update") - try: from hermes_cli.gitlock import fetch_full_commit_graph from hermes_cli.update_cmd import _no_prompt_git_kwargs diff --git a/hermes_cli/update_completion.py b/hermes_cli/update_completion.py index cb5df28acc..011e57a893 100644 --- a/hermes_cli/update_completion.py +++ b/hermes_cli/update_completion.py @@ -155,6 +155,8 @@ def _prepare(request: dict, request_path: Path, result_path: Path) -> int: refuse_foreign_owned_venv(root) arm_completion(root) + from hermes_cli.gitlock import convert_treeless_checkout_first + convert_treeless_checkout_first(root) with receipt.worker_context(update_id): try: # This file runs from the new tree, so its lockfile carries the new diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 4bf466b885..f8dfe38200 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -819,6 +819,19 @@ function Stage-Repository { } } Disable-TreelessGraphWrites $InstallDir + # A treeless (tree:0) checkout from a late-September installer downloads whole + # directory snapshots again on every history walk (#129514). Fetch its trees once; the + # new filter is recorded only after that succeeds, so `hermes update` retries otherwise. + $partialFilter = Invoke-Native { git -C $InstallDir config --get remote.origin.partialclonefilter } + if ("$partialFilter".Trim() -eq 'tree:0') { + Invoke-Logged -MayFail "Fetching directory history once (treeless checkout)" { git -C $InstallDir -c gc.auto=0 -c maintenance.auto=false fetch --refetch --filter=blob:none origin "+refs/heads/${Branch}:refs/remotes/origin/${Branch}" } + if ($LASTEXITCODE) { + Write-Warn "could not fetch the directory history; the next hermes update retries it" + } else { + Invoke-Native { git -C $InstallDir config remote.origin.partialclonefilter blob:none } + if ($LASTEXITCODE) { Write-Warn "could not record the blobless filter in $InstallDir" } + } + } } Invoke-Logged "Fetching origin/$Branch" { git -C $InstallDir fetch origin "+refs/heads/${Branch}:refs/remotes/origin/${Branch}" } if ($LASTEXITCODE) { Fail "git fetch failed" } diff --git a/scripts/install.sh b/scripts/install.sh index f94810df46..a576ee5e27 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -491,6 +491,19 @@ stage_repository() { || log_warn "could not disable gc.writeCommitGraph in $INSTALL_DIR" git -C "$INSTALL_DIR" config fetch.writeCommitGraph false \ || log_warn "could not disable fetch.writeCommitGraph in $INSTALL_DIR" + # A treeless (tree:0) checkout from a late-September installer downloads whole + # directory snapshots again on every history walk (#129514). Fetch its trees once; the + # new filter is recorded only after that succeeds, so `hermes update` retries otherwise. + if [ "$(git -C "$INSTALL_DIR" config --get remote.origin.partialclonefilter)" = tree:0 ]; then + if run_logged --may-fail "Fetching directory history once (treeless checkout)" \ + git -C "$INSTALL_DIR" -c gc.auto=0 -c maintenance.auto=false fetch --refetch \ + --filter=blob:none origin "+refs/heads/$BRANCH:refs/remotes/origin/$BRANCH"; then + git -C "$INSTALL_DIR" config remote.origin.partialclonefilter blob:none \ + || log_warn "could not record the blobless filter in $INSTALL_DIR" + else + log_warn "could not fetch the directory history; the next hermes update retries it" + fi + fi fi run_logged "Fetching origin/$BRANCH" git -C "$INSTALL_DIR" fetch origin "+refs/heads/$BRANCH:refs/remotes/origin/$BRANCH" \ || fail "git fetch failed" diff --git a/tests/hermes_cli/test_git_pack_tidy.py b/tests/hermes_cli/test_git_pack_tidy.py index c3c0ab1c2c..ced9449d60 100644 --- a/tests/hermes_cli/test_git_pack_tidy.py +++ b/tests/hermes_cli/test_git_pack_tidy.py @@ -173,7 +173,12 @@ def test_a_refused_or_killed_erase_leaves_git_reading_and_is_finished_later( assert tidy.tidy_partial_clone_packs(clone) == tidy.TidyResult(), "ran beside a live lock holder" finally: os.close(holder) + killed_fetch, live_fetch = pack_dir / "tmp_pack_killed", pack_dir / "tmp_pack_live" + for temp in (killed_fetch, live_fetch): + temp.write_bytes(b"partial transfer") + os.utime(killed_fetch, (time.time() - 2 * 3600,) * 2) tidy.tidy_partial_clone_packs(clone) assert all(p.with_suffix(".pack").exists() for p in pack_dir.glob("pack-*.*")), \ "leftovers of an interrupted erase survived the next run" + assert not killed_fetch.exists() and live_fetch.exists(), "a dead fetch's temp file survived, or a live one went" assert _objects(clone) == held diff --git a/tests/hermes_cli/test_update_completion_process.py b/tests/hermes_cli/test_update_completion_process.py index 4ae48c9147..91158c51c3 100644 --- a/tests/hermes_cli/test_update_completion_process.py +++ b/tests/hermes_cli/test_update_completion_process.py @@ -112,6 +112,10 @@ def transition(tmp_path): package / "update_lock.py") shutil.copy2(Path(update_completion.__file__).with_name("_subprocess_compat.py"), package / "_subprocess_compat.py") + (package / "gitlock.py").write_text( + "from hermes_cli.probe import event\n" + "convert_treeless_checkout_first = lambda root: event('convert')\n" + ) (package / "main.py").write_text("") (package / "update_cmd_config.py").write_text("_LAST_SIBLING_SNAPSHOTS = {}\n") (package / "update_inventory.py").write_text( @@ -218,6 +222,10 @@ def test_old_process_new_git_tree_completes_in_fresh_python(transition, tmp_path by_name = {event["name"]: event for event in events} assert by_name["activate"]["pid"] == by_name["build"]["pid"] assert by_name["prepare"]["pid"] != by_name["build"]["pid"] + # A treeless checkout converts before the dependency work, where a pre-fix Desktop's history + # walks filled disks (#129514). + names = [e["name"] for e in events] + assert by_name["convert"]["pid"] == by_name["prepare"]["pid"] and names.index("convert") < names.index("tools") assert Path(by_name["build"]["python"]).is_relative_to(root.parent / "selected-python") assert by_name["build"]["pid"] == by_name["maintenance"]["pid"] == by_name["restart"]["pid"] assert by_name["maintenance"]["snapshots"] == {"work": "work-before"} diff --git a/website/docs/getting-started/updating.md b/website/docs/getting-started/updating.md index 335fef704f..a9e52db2d9 100644 --- a/website/docs/getting-started/updating.md +++ b/website/docs/getting-started/updating.md @@ -173,9 +173,30 @@ and git downloads file contents on demand, each on-demand download written as it Installers from late September 2026 made treeless (`--filter=tree:0`) clones instead. git asks for a missing tree without saying which ones it already has, so a treeless checkout downloaded complete directory snapshots again on every checkout and path-filtered history walk. `hermes update` -converts such a checkout once, at the end of the update: one `git fetch --refetch --filter=blob:none` -brings every commit and tree (about 120 MB), and later updates stop re-downloading them. If that -fetch fails, the update prints a warning, carries on, and retries the conversion next time. `hermes update` and `hermes update --check` +converts such a checkout once, as its first step after pulling the new code and before the +dependency work: one `git fetch --refetch --filter=blob:none` brings every commit and tree (about +120 MB), and later updates stop re-downloading them. Re-running the installer over such a checkout +converts it the same way. If that fetch fails, the update prints a warning, carries on, and retries +the conversion next time. + +A Hermes Desktop built before the conversion existed runs path-filtered history walks every few +minutes, so on a treeless checkout it could fill a disk with pack files (one report reached 434 GB) +and keep going after a failed update. To recover when that already happened: + +1. Quit Hermes Desktop, then end any leftover `git rev-list`, `git maintenance` or + `git commit-graph` processes; quitting the app does not stop them. +2. If the disk is completely full, delete the abandoned transfer files to get room back: + `rm -f "$repo"/.git/objects/pack/tmp_pack_*` (Windows: delete `tmp_pack_*` in + `.git\objects\pack`). Later updates sweep any that are more than an hour old on their own. +3. Run `hermes update`. It converts the checkout first, then cleans the pack pile down over this + and later updates as described below. + +If `hermes update` itself cannot start, the conversion by hand is: + +```bash +git -C "$repo" fetch --refetch --filter=blob:none origin +git -C "$repo" config remote.origin.partialclonefilter blob:none +``` `hermes update` and `hermes update --check` set `maintenance.commit-graph.enabled`, `gc.writeCommitGraph` and `fetch.writeCommitGraph` to `false` in that checkout, because a commit-graph write over commits the graph has not seen yet downloads every one of their trees. Leave those settings alone, and leave `gc.auto` at its