fix(update): convert a treeless checkout before the dependency work, and on installer re-runs

A Desktop built before the blobless fix keeps walking history during
`hermes update`. On a treeless (tree:0) checkout every walk downloads trees
again, and #129514 filled a 434 GB disk during the minutes of dependency work.
The one-time conversion (#133016) ran at the very end of the update, after that
window. Both update hand-offs (update_completion._prepare and an older
updater's _update_takeover.prepare) now run it first, in the bootstrap
interpreter, and the late call is gone.

Re-running install.sh / install.ps1 over a treeless checkout now converts it
too. The filter is recorded only after the refetch succeeds, so a failure is
retried by the next update instead of leaving a blob:none filter over a
history that has no trees.

The pack cleanup also sweeps tmp_* transfer files that killed fetches left
behind. They are more than an hour old, a storm leaves gigabytes of them,
and git itself only prunes them after two weeks. The docs gain the recovery
steps for a disk that the storm already filled.

Co-authored-by: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com>
Co-authored-by: Yuan Li <dskwelmcy@163.com>
This commit is contained in:
Teknium
2026-10-05 16:23:57 -07:00
parent e7de41ca6c
commit 1c97b57a93
10 changed files with 105 additions and 19 deletions

View File

@@ -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

View File

@@ -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() \

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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" }

View File

@@ -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"

View File

@@ -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

View File

@@ -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"}

View File

@@ -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