fix(update): a clashing local tag or old git no longer leaves the conversion retrying forever

Final-review findings on convert_treeless_checkout:
- A local tag that the --tags refetch would clobber made git exit non-zero
  after the objects had landed, and check=True raised before the
  completeness check ran, so every update warned and stayed tree:0. The
  offline history check is now the verdict, not the fetch's exit status.
- Git older than 2.36 has no --refetch; such a checkout is left as it is
  instead of printing git's usage text as a warning on every update.
This commit is contained in:
Teknium
2026-10-05 00:56:58 -07:00
parent 0b570cb34c
commit e1fdf003a6
2 changed files with 22 additions and 5 deletions

View File

@@ -7,6 +7,7 @@ from __future__ import annotations
import logging
import os
import re
import subprocess
import time
from pathlib import Path
@@ -620,23 +621,26 @@ def convert_treeless_checkout(repo_root: Path, **run_kwargs) -> bool:
release ref the refspec does not name), so its history is checked offline and refetched by
commit when trees are still missing. The new filter is recorded only once that history is
whole: a failed or partial conversion leaves the checkout treeless, so the next update
retries. Returns whether it converted; fetch failures raise subprocess errors.
retries. That check, not the fetch's exit status, is the verdict: a refused tag update (a
local tag that would be clobbered) fails the fetch after its objects have landed. Git before
2.36 has no ``--refetch`` and is left as it is. Returns whether it converted; fetch failures
raise subprocess errors.
"""
run_kwargs["creationflags"] = run_kwargs.get("creationflags", 0) | windows_hide_flags()
if _partial_clone_filter(repo_root, **run_kwargs) != "tree:0":
if _partial_clone_filter(repo_root, **run_kwargs) != "tree:0" or _git_version(**run_kwargs) < (2, 36):
return False
# A fetch spawns a detached gc/maintenance that would repack the whole refetch outside the
# update's time limit; the per-command keys leave the user's own settings alone.
refetch = ["git", "-c", "gc.auto=0", "-c", "maintenance.auto=false", "fetch", "--quiet", "--refetch",
"--filter=blob:none", "origin"]
fetch_kwargs = dict(cwd=str(repo_root), check=True, capture_output=True, text=True,
fetch_kwargs = dict(cwd=str(repo_root), capture_output=True, text=True,
encoding="utf-8", errors="replace", timeout=900, **run_kwargs)
subprocess.run([*refetch, "--tags"], **fetch_kwargs)
subprocess.run([*refetch, "--tags"], check=False, **fetch_kwargs)
if not _history_trees_complete(repo_root, **run_kwargs):
head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=str(repo_root), check=True,
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30,
**run_kwargs).stdout.strip()
subprocess.run([*refetch, head], **fetch_kwargs)
subprocess.run([*refetch, head], check=True, **fetch_kwargs)
if not _history_trees_complete(repo_root, **run_kwargs):
raise subprocess.CalledProcessError(1, refetch, stderr="the checked-out history is still missing trees")
# A refetch into an existing partial clone leaves its configured filter alone; record the
@@ -648,6 +652,13 @@ def convert_treeless_checkout(repo_root: Path, **run_kwargs) -> bool:
return 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
match = re.search(r"(\d+)\.(\d+)", out)
return (int(match[1]), int(match[2])) if match else (0, 0)
def _history_trees_complete(repo_root: Path, **run_kwargs) -> bool:
"""Whether every tree in HEAD's history is local, checked without fetching (~3 s for this repo)."""
env = dict(run_kwargs.pop("env", None) or noninteractive_git_env(), GIT_NO_LAZY_FETCH="1")

View File

@@ -88,6 +88,11 @@ def _check_out_tag_only_release(clone: Path) -> None:
_git(clone, "checkout", "-q", "--detach", "v9")
def _check_out_release_under_a_clashing_local_tag(clone: Path) -> None:
_check_out_tag_only_release(clone)
_git(clone, "tag", "-f", "v9", "origin/main") # the tag refetch is now refused: would clobber
def _check_out_branch_outside_the_refspec(clone: Path) -> None:
_git(clone, "config", "remote.origin.fetch", "+refs/heads/main:refs/remotes/origin/main")
_git(clone, "fetch", "-q", "origin", "side:side")
@@ -97,6 +102,7 @@ def _check_out_branch_outside_the_refspec(clone: Path) -> None:
@pytest.mark.parametrize("ref, check_out, history", [
("refs/heads/side", _check_out_main, 5),
("refs/tags/v9", _check_out_tag_only_release, 7),
("refs/tags/v9", _check_out_release_under_a_clashing_local_tag, 7),
("refs/heads/side", _check_out_branch_outside_the_refspec, 7),
])
def test_treeless_checkout_gets_its_whole_history_once_so_walks_stay_offline(tmp_path, ref, check_out, history):