fix(install): Windows installs under a long profile path no longer fail "Clone succeeded, but checkout failed"

install.ps1 clones into %LOCALAPPDATA%\hermes\.hermes-clone-<pid>-<random>\tree
before publishing, which puts the deepest tracked file (a 159-character zh-Hans
docs path) past MAX_PATH once LOCALAPPDATA is longer than ~55 characters. Git
for Windows then refuses the file ("Filename too long"), every clone attempt
and the deferred checkout fail the same way, and the user sees "fatal: unable to
checkout working tree" from both the PowerShell one-liner and Hermes-Setup.

Clone with --config core.longpaths=true so the setting lands before the first
checkout and stays in the repo for `hermes update`; an installer rerun over an
existing checkout sets it too.

Salvages the long-path half of #72744 by @Imagineer99, written against the
pre-rewrite installer.

Co-authored-by: Lee Jackson <samleejackson0@gmail.com>
This commit is contained in:
TekniumandLee Jackson committed 2026-10-12 16:42:41 -07:00
1 parent 562fda904c
commit 3b63f7b0a4
2 files changed
+39 -4

No files matched your search

+8 -2
View File
@@ -870,6 +870,8 @@ function Stage-Repository {
}
if (Test-Path (Join-Path $InstallDir ".git")) {
Log "Updating $InstallDir ($Branch)"
# A checkout cloned before longpaths was set: a deep path past MAX_PATH fails checkout.
Invoke-Native { git -C $InstallDir config core.longpaths true } | Out-Null
# An explicit HERMES_REPO_URL names the source for reruns too, not
# just the first clone.
if ($env:HERMES_REPO_URL) {
@@ -989,6 +991,10 @@ function Stage-Repository {
# branch tip first fetches and rewrites every file the two differ in.
$checkout = @()
if ($Commit) { $checkout = @('--no-checkout') }
# Staging dir + deepest tracked path (~160) pass MAX_PATH (260) once LOCALAPPDATA
# is longer than ~55 characters (a ~33-character user name), and checkout dies with
# "Filename too long". --config lands before checkout and stays for `hermes update`.
$longPaths = @('--config', 'core.longpaths=true')
try {
$cloned = $false
$deferred = $false
@@ -1000,7 +1006,7 @@ function Stage-Repository {
# checkout and history walk (#129712).
$cloneLabel = "Cloning $RepoUrl ($Branch) into $InstallDir"
if ($attempt -gt 1) { $cloneLabel += " (attempt $attempt of 3)" }
Invoke-Logged $cloneLabel { git clone @progress --filter=blob:none @checkout --branch $Branch $RepoUrl $tree }
Invoke-Logged $cloneLabel { git clone @progress @longPaths --filter=blob:none @checkout --branch $Branch $RepoUrl $tree }
if (-not $LASTEXITCODE) { $cloned = $true; break }
Remove-Item -LiteralPath $tree -Recurse -Force -ErrorAction SilentlyContinue
if ($attempt -lt 3) { Start-Sleep -Seconds ($attempt * 5) }
@@ -1009,7 +1015,7 @@ function Stage-Repository {
# The checkout step is where throttled downloads die: clone the
# graph alone, then retry materializing the tree separately.
Write-Warn "direct clone failed; trying deferred checkout"
Invoke-Logged "Cloning history" { git clone @progress --filter=blob:none --no-checkout --branch $Branch $RepoUrl $tree }
Invoke-Logged "Cloning history" { git clone @progress @longPaths --filter=blob:none --no-checkout --branch $Branch $RepoUrl $tree }
if (-not $LASTEXITCODE) { $cloned = $true; $deferred = $true }
}
if (-not $cloned) { Fail "git clone failed; no checkout published$(Get-NativeDetail)" git_clone_failed }
@@ -66,9 +66,10 @@ def _run_repository_stage(installer: str, tmp_path: Path, install: Path, origin_
# pwsh on POSIX cannot run the Windows-only pinned Git: seed its PM store slot with host git.
version = json.loads((ROOT / "pm" / "lock.json").read_text(encoding="utf-8-sig"))["packages"]["git"]["version"]
cmd = tmp_path / "tools" / f"git-{version}-win32-x64" / "cmd"
cmd.mkdir(parents=True)
cmd.mkdir(parents=True, exist_ok=True)
for name in ("git.exe", "git"):
(cmd / name).symlink_to(shutil.which("git"))
if not (cmd / name).exists():
(cmd / name).symlink_to(shutil.which("git"))
env.update(HERMES_RUNTIME_DIR=str(tmp_path / "tools"), PATH=str(cmd) + os.pathsep + env["PATH"])
return subprocess.run([POWERSHELL, "-NoProfile", "-File", str(ROOT / "scripts" / "install.ps1"),
"-Stage", "repository", "-NonInteractive", "-InstallDir", str(install),
@@ -130,3 +131,31 @@ def test_clone_failure_reason_carries_gits_own_error(tmp_path, installer):
assert result.returncode != 0
assert "git clone failed; no checkout published: fatal:" in output, output
assert "no-such-repo.git" in output.split("no checkout published: fatal:", 1)[1].splitlines()[0]
@pytest.mark.live_system_guard_bypass
@pytest.mark.skipif(POWERSHELL is None, reason="needs PowerShell")
def test_ps1_checkout_keeps_long_paths_enabled(tmp_path):
"""Windows checkouts sit past MAX_PATH under a long profile root: git must be told to allow it."""
origin = tmp_path / "origin.git"
source = tmp_path / "source"
install = tmp_path / "install"
subprocess.run(["git", "init", "--bare", "-b", "main", str(origin)], check=True, capture_output=True)
def git(cwd, *args):
return subprocess.run(["git", "-C", str(cwd), *args], check=False,
capture_output=True, text=True, encoding="utf-8", errors="replace")
source.mkdir()
for args in (("init", "-b", "main"), ("config", "user.email", "fixture@example.invalid"),
("config", "user.name", "Fixture"), ("commit", "--allow-empty", "-m", "fixture"),
("push", origin.as_posix(), "main")):
assert git(source, *args).returncode == 0
git(origin, "config", "uploadpack.allowFilter", "true")
fresh = _run_repository_stage("ps1", tmp_path, install, origin.as_uri())
assert fresh.returncode == 0, fresh.stdout + fresh.stderr
assert git(install, "config", "--local", "--get", "core.longpaths").stdout.strip() == "true"
git(install, "config", "--local", "--unset", "core.longpaths") # a checkout from an older installer
rerun = _run_repository_stage("ps1", tmp_path, install, origin.as_uri())
assert rerun.returncode == 0, rerun.stdout + rerun.stderr
assert git(install, "config", "--local", "--get", "core.longpaths").stdout.strip() == "true"