{"repo":"NousResearch/hermes-agent","public_id":"40998","display_label":"Handle interrupted or incomplete install/clone","summary":"These items address interrupted checkouts that leave a commit-less state, triggering re-cloning rather than an error, alongside a reported Windows installation issue. The common thread is install-time errors and recovery behavior.","subsystem":"hermes_cli","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":41941,"merged_at":"2026-06-08T09:18:22+00:00"},"resolution":{"state":"resolved","by":"pr-41941"},"freshness":{"state":"verified","last_verified_at":"2026-06-08T09:18:22+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":40998,"pr_number":41941,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-08T09:18:22+00:00"}],"members":[{"kind":"issue","number":40998,"title":"[Bug]: windows安装包安装的时候安装失败","url":null,"state":"closed"},{"kind":"pr","number":41208,"title":"fix(install): re-clone interrupted (commit-less) checkout instead of failing update","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#41941","number":41941},{"kind":"pr","value":"#40998","number":40998}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":41941,"title":"fix(install): re-clone interrupted (commit-less) checkout instead of failing update","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["scripts/install.ps1","scripts/install.sh","tests/test_install_no_initial_commit.py"],"fix_hunks":[{"file":"scripts/install.ps1","hunks":["@@ -1060,9 +1060,10 @@ function Install-Repository {\n         # directory OR a symlink OR a submodule-style gitfile -- and also when\n         # it's a broken stub left over from a failed previous install (e.g.\n         # a partial Remove-Item that couldn't delete a locked index.lock).\n-        # Validate the repo properly by asking git itself.  Two checks\n-        # belt-and-braces: rev-parse AND git status.  If either fails the\n-        # repo is broken and we fall through to a fresh clone.\n+        # Validate the repo properly by asking git itself.  Three checks\n+        # belt-and-braces: rev-parse (work tree), git status, and a resolvable\n+        # HEAD (an initial commit).  If any fails the repo is broken and we\n+        # fall through to a fresh clone.\n         $repoValid = $false\n         if (Test-Path \"$InstallDir\\.git\") {\n             Push-Location $InstallDir","@@ -1077,7 +1078,17 @@ function Install-Repository {\n                 $null = & git -c windows.appendAtomically=false status --short 2>&1\n                 $statusOk = ($LASTEXITCODE -eq 0)\n \n-                if ($revParseOk -and $statusOk) {\n+                # An interrupted previous clone leaves a repo with NO initial\n+                # commit. rev-parse/status still succeed there, but the update\n+                # path's `git stash` (and later `git checkout`) abort with\n+                # \"You do not have the initial commit yet\" and fail the install\n+                # (#40998). Require a resolvable HEAD so such partial checkouts\n+                # are treated as broken and re-cloned fresh below.\n+                $global:LASTEXITCODE = 0\n+                $null = & git -c windows.appendAtomically=false rev-parse --verify HEAD 2>&1\n+                $hasCommit = ($LASTEXITCODE -eq 0)\n+\n+                if ($revParseOk -and $statusOk -and $hasCommit) {\n                     $repoValid = $true\n                 }\n             } catch {}","@@ -1198,16 +1209,19 @@ function Install-Repository {\n             }\n             $didUpdate = $true\n         } else {\n-            # Directory exists but isn't a usable git repo.  Wipe it and\n-            # fall through to a fresh clone.  A leftover ``.git`` stub from\n-            # a partial uninstall used to lock the installer into the\n-            # \"update\" branch forever, emitting three ``fatal: not a git\n-            # repository`` errors and failing with \"not in a git directory\".\n-            Write-Warn \"Existing directory at $InstallDir is not a valid git repo -- replacing it.\"\n+            # Directory exists but isn't a usable git repo -- e.g. an\n+            # interrupted clone with no initial commit (#40998), or a leftover\n+            # ``.git`` stub from a partial uninstall that used to lock the\n+            # installer into the \"update\" branch forever. Move it aside rather\n+            # than deleting it -- never destroy a directory the user might still\n+            # want -- and fall through to a fresh clone.\n+            $backupDir = \"$InstallDir.broken-\" + (Get-Date -Format \"yyyyMMdd-HHmmss\")\n+            Write-Warn \"Existing directory at $InstallDir is not a valid git repo.\"\n+            Write-Warn \"Moving it aside to $backupDir before re-cloning.\"\n             try {\n-                Remove-Item -Recurse -Force $InstallDir -ErrorAction Stop\n+                Move-Item -LiteralPath $InstallDir -Destination $backupDir -ErrorAction Stop\n             } catch {\n-                Write-Err \"Could not remove $InstallDir : $_\"\n+                Write-Err \"Could not move $InstallDir aside : $_\"\n                 Write-Info \"Close any programs that might be using files in $InstallDir (editors,\"\n                 Write-Info \"terminals, running hermes processes) and try again.\"\n                 throw"]},{"file":"scripts/install.sh","hunks":["@@ -1092,6 +1092,18 @@ show_manual_install_hint() {\n clone_repo() {\n     log_info \"Installing to $INSTALL_DIR...\"\n \n+    # An interrupted previous clone leaves a .git with no initial commit, where\n+    # the update path's `git stash` / `git checkout` abort with \"You do not\n+    # have the initial commit yet\" and fail the install (#40998). Move such a\n+    # partial checkout aside -- never delete it, in case it holds something the\n+    # user wants -- so the fresh-clone path below can proceed.\n+    if [ -d \"$INSTALL_DIR/.git\" ] && ! git -C \"$INSTALL_DIR\" rev-parse --verify HEAD >/dev/null 2>&1; then\n+        backup_dir=\"${INSTALL_DIR}.broken-$(date -u +%Y%m%d-%H%M%S)\"\n+        log_warn \"Existing checkout at $INSTALL_DIR has no commits (interrupted clone).\"\n+        log_warn \"Moving it aside to $backup_dir before re-cloning.\"\n+        mv \"$INSTALL_DIR\" \"$backup_dir\"\n+    fi\n+\n     if [ -d \"$INSTALL_DIR\" ]; then\n         if [ -d \"$INSTALL_DIR/.git\" ]; then\n             log_info \"Existing installation found, updating...\""]},{"file":"tests/test_install_no_initial_commit.py","hunks":["@@ -0,0 +1,136 @@\n+\"\"\"Regression for #40998: installer fails on an interrupted prior clone.\n+\n+A previous clone that died before its first commit leaves ``$INSTALL_DIR/.git``\n+present but with no resolvable ``HEAD``. ``git rev-parse --is-inside-work-tree``\n+and ``git status`` both still succeed there, so the installer treated it as a\n+valid checkout and tried to *update* it -- but ``git stash``/``git checkout``\n+abort with \"You do not have the initial commit yet\", failing the install at the\n+\"Cloning Hermes repository\" stage.\n+\n+Both installers must instead treat a commit-less checkout as broken and\n+re-clone fresh.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import re\n+import shlex\n+import shutil\n+import subprocess\n+from pathlib import Path\n+\n+import pytest\n+\n+REPO_ROOT = Path(__file__).resolve().parent.parent\n+INSTALL_SH = REPO_ROOT / \"scripts\" / \"install.sh\"\n+INSTALL_PS1 = REPO_ROOT / \"scripts\" / \"install.ps1\"\n+\n+pytestmark = pytest.mark.skipif(\n+    shutil.which(\"git\") is None or shutil.which(\"bash\") is None,\n+    reason=\"needs git and bash\",\n+)\n+\n+\n+def _git(cwd: Path, *args: str) -> None:\n+    subprocess.run(\n+        [\"git\", \"-c\", \"user.email=t@t\", \"-c\", \"user.name=t\", *args],\n+        cwd=cwd,\n+        check=True,\n+        capture_output=True,\n+    )\n+\n+\n+def _extract_no_commit_guard() -> str:\n+    \"\"\"Pull the clone_repo() guard that drops a commit-less checkout.\"\"\"\n+    text = INSTALL_SH.read_text()\n+    m = re.search(\n+        r'if \\[ -d \"\\$INSTALL_DIR/\\.git\" \\] && ! git -C \"\\$INSTALL_DIR\" '\n+        r\"rev-parse --verify HEAD.*?\\n    fi\",\n+        text,\n+        re.DOTALL,\n+    )\n+    assert m is not None, \"no-commit guard not found in install.sh clone_repo()\"\n+    return m.group(0)\n+\n+\n+def _run_guard(install_dir: Path) -> None:\n+    block = _extract_no_commit_guard()\n+    script = (\n+        \"log_warn() { echo \\\"WARN: $*\\\"; }\\n\"\n+        f\"INSTALL_DIR={shlex.quote(str(install_dir))}\\n\"\n+        f\"{block}\\n\"\n+    )\n+    res = subprocess.run([\"bash\", \"-c\", script], capture_output=True, text=True)\n+    assert res.returncode == 0, res.stderr\n+\n+\n+def test_install_sh_guard_moves_commitless_checkout_aside(tmp_path: Path) -> None:\n+    install_dir = tmp_path / \"hermes-agent\"\n+    install_dir.mkdir()\n+    _git(install_dir, \"init\")\n+    (install_dir / \"leftover.txt\").write_text(\"partial download\")  # untracked\n+\n+    # Sanity: this is exactly the state that breaks `git stash`.\n+    head = subprocess.run(\n+        [\"git\", \"-C\", str(install_dir), \"rev-parse\", \"--verify\", \"HEAD\"],\n+        capture_output=True,\n+    )\n+    assert head.returncode != 0\n+\n+    _run_guard(install_dir)\n+    # The original path is cleared so a fresh clone can proceed, but the\n+    # content is preserved in a backup (never deleted -- review feedback).\n+    assert not install_dir.exists(), \"commit-less checkout should be moved aside\"\n+    backups = list(install_dir.parent.glob(install_dir.name + \".broken-*\"))\n+    assert len(backups) == 1, \"broken checkout should be moved to one backup dir\"\n+    assert (backups[0] / \"leftover.txt\").read_text() == \"partial download\"\n+\n+\n+def test_install_sh_guard_keeps_repo_with_commits(tmp_path: Path) -> None:\n+    install_dir = tmp_path / \"hermes-agent\"\n+    install_dir.mkdir()\n+    _git(install_dir, \"init\")\n+    (install_dir / \"f.txt\").write_text(\"real content\")\n+    _git(install_dir, \"add\", \"f.txt\")\n+    _git(install_dir, \"commit\", \"-m\", \"init\")\n+\n+    _run_guard(install_dir)\n+    assert install_dir.exists()\n+    assert (install_dir / \"f.txt\").exists(), \"a real checkout must be left intact\"\n+    assert not list(install_dir.parent.glob(install_dir.name + \".broken-*\")), (\n+        \"a healthy checkout must not be moved aside\"\n+    )\n+\n+\n+def test_install_sh_guard_ignores_non_repo_dir(tmp_path: Path) -> None:\n+    install_dir = tmp_path / \"hermes-agent\"\n+    install_dir.mkdir()\n+    (install_dir / \"f.txt\").write_text(\"not a repo\")\n+\n+    _run_guard(install_dir)\n+    # No .git → not our concern; the existing \"not a git repository\" branch\n+    # still handles it. The guard must leave it untouched.\n+    assert install_dir.exists()\n+    assert (install_dir / \"f.txt\").exists()\n+\n+\n+def test_install_ps1_validity_requires_initial_commit() -> None:\n+    \"\"\"The PowerShell repo-validity gate must also require a resolvable HEAD.\"\"\"\n+    text = INSTALL_PS1.read_text()\n+    assert \"rev-parse --verify HEAD\" in text, (\n+        \"install.ps1 must probe for an initial commit (#40998)\"\n+    )\n+    # Contract: $repoValid is only set when the HEAD probe succeeded too.\n+    assert re.search(\n+        r\"if \\(\\$revParseOk -and \\$statusOk -and \\$hasCommit\\) \\{\",\n+        text,\n+    ), \"repo validity must be gated on $hasCommit, not just rev-parse + status\"\n+    # Cleanup must be non-destructive: move the broken checkout aside, never\n+    # `Remove-Item -Recurse -Force` it (review feedback on #40998).\n+    assert \"Move-Item -LiteralPath $InstallDir\" in text, (\n+        \"install.ps1 must move an invalid checkout aside, not delete it\"\n+    )\n+    assert \"Remove-Item -Recurse -Force $InstallDir -ErrorAction Stop\" not in text, (\n+        \"the destructive wipe of an existing install dir must be gone \"\n+        \"(transient cleanup of a just-failed clone is fine)\"\n+    )\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}