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:
@@ -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
|
||||
|
||||
@@ -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() \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user