# NousResearch/hermes-agent · Handle interrupted or incomplete install/clone

## Open now

- Nothing open on GitHub.

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.

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #41941. Verify it addresses your case.
- **Recommended:** read PR #41941 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-41941`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #40998: \[Bug\]: windows安装包安装的时候安装失败

## Prior & competing PRs

- **#41941: fix(install): re-clone interrupted (commit-less) checkout instead of failing update**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `scripts/install.ps1`, `scripts/install.sh`, `tests/test_install_no_initial_commit.py`
  - fix hunks:
  - `scripts/install.ps1`

    ```diff
    @@ -1060,9 +1060,10 @@ function Install-Repository {
             # directory OR a symlink OR a submodule-style gitfile -- and also when
             # it's a broken stub left over from a failed previous install (e.g.
             # a partial Remove-Item that couldn't delete a locked index.lock).
    -        # Validate the repo properly by asking git itself.  Two checks
    -        # belt-and-braces: rev-parse AND git status.  If either fails the
    -        # repo is broken and we fall through to a fresh clone.
    +        # Validate the repo properly by asking git itself.  Three checks
    +        # belt-and-braces: rev-parse (work tree), git status, and a resolvable
    +        # HEAD (an initial commit).  If any fails the repo is broken and we
    +        # fall through to a fresh clone.
             $repoValid = $false
             if (Test-Path "$InstallDir\.git") {
                 Push-Location $InstallDir
    ```

    ```diff
    @@ -1077,7 +1078,17 @@ function Install-Repository {
                     $null = & git -c windows.appendAtomically=false status --short 2>&1
                     $statusOk = ($LASTEXITCODE -eq 0)
     
    -                if ($revParseOk -and $statusOk) {
    +                # An interrupted previous clone leaves a repo with NO initial
    +                # commit. rev-parse/status still succeed there, but the update
    +                # path's `git stash` (and later `git checkout`) abort with
    +                # "You do not have the initial commit yet" and fail the install
    +                # (#40998). Require a resolvable HEAD so such partial checkouts
    +                # are treated as broken and re-cloned fresh below.
    +                $global:LASTEXITCODE = 0
    +                $null = & git -c windows.appendAtomically=false rev-parse --verify HEAD 2>&1
    +                $hasCommit = ($LASTEXITCODE -eq 0)
    +
    +                if ($revParseOk -and $statusOk -and $hasCommit) {
                         $repoValid = $true
                     }
                 } catch {}
    ```

    ```diff
    @@ -1198,16 +1209,19 @@ function Install-Repository {
                 }
                 $didUpdate = $true
             } else {
    -            # Directory exists but isn't a usable git repo.  Wipe it and
    -            # fall through to a fresh clone.  A leftover ``.git`` stub from
    -            # a partial uninstall used to lock the installer into the
    -            # "update" branch forever, emitting three ``fatal: not a git
    -            # repository`` errors and failing with "not in a git directory".
    -            Write-Warn "Existing directory at $InstallDir is not a valid git repo -- replacing it."
    +            # Directory exists but isn't a usable git repo -- e.g. an
    +            # interrupted clone with no initial commit (#40998), or a leftover
    +            # ``.git`` stub from a partial uninstall that used to lock the
    +            # installer into the "update" branch forever. Move it aside rather
    +            # than deleting it -- never destroy a directory the user might still
    +            # want -- and fall through to a fresh clone.
    +            $backupDir = "$InstallDir.broken-" + (Get-Date -Format "yyyyMMdd-HHmmss")
    +            Write-Warn "Existing directory at $InstallDir is not a valid git repo."
    +            Write-Warn "Moving it aside to $backupDir before re-cloning."
                 try {
    -                Remove-Item -Recurse -Force $InstallDir -ErrorAction Stop
    +                Move-Item -LiteralPath $InstallDir -Destination $backupDir -ErrorAction Stop
                 } catch {
    -                Write-Err "Could not remove $InstallDir : $_"
    +                Write-Err "Could not move $InstallDir aside : $_"
                     Write-Info "Close any programs that might be using files in $InstallDir (editors,"
                     Write-Info "terminals, running hermes processes) and try again."
                     throw
    ```
  - `scripts/install.sh`

    ```diff
    @@ -1092,6 +1092,18 @@ show_manual_install_hint() {
     clone_repo() {
         log_info "Installing to $INSTALL_DIR..."
     
    +    # An interrupted previous clone leaves a .git with no initial commit, where
    +    # the update path's `git stash` / `git checkout` abort with "You do not
    +    # have the initial commit yet" and fail the install (#40998). Move such a
    +    # partial checkout aside -- never delete it, in case it holds something the
    +    # user wants -- so the fresh-clone path below can proceed.
    +    if [ -d "$INSTALL_DIR/.git" ] && ! git -C "$INSTALL_DIR" rev-parse --verify HEAD >/dev/null 2>&1; then
    +        backup_dir="${INSTALL_DIR}.broken-$(date -u +%Y%m%d-%H%M%S)"
    +        log_warn "Existing checkout at $INSTALL_DIR has no commits (interrupted clone)."
    +        log_warn "Moving it aside to $backup_dir before re-cloning."
    +        mv "$INSTALL_DIR" "$backup_dir"
    +    fi
    +
         if [ -d "$INSTALL_DIR" ]; then
             if [ -d "$INSTALL_DIR/.git" ]; then
                 log_info "Existing installation found, updating..."
    ```
  - `tests/test_install_no_initial_commit.py`

    ```diff
    @@ -0,0 +1,136 @@
    +"""Regression for #40998: installer fails on an interrupted prior clone.
    +
    +A previous clone that died before its first commit leaves ``$INSTALL_DIR/.git``
    +present but with no resolvable ``HEAD``. ``git rev-parse --is-inside-work-tree``
    +and ``git status`` both still succeed there, so the installer treated it as a
    +valid checkout and tried to *update* it -- but ``git stash``/``git checkout``
    +abort with "You do not have the initial commit yet", failing the install at the
    +"Cloning Hermes repository" stage.
    +
    +Both installers must instead treat a commit-less checkout as broken and
    +re-clone fresh.
    +"""
    +
    +from __future__ import annotations
    +
    +import re
    +import shlex
    +import shutil
    +import subprocess
    +from pathlib import Path
    +
    +import pytest
    +
    +REPO_ROOT = Path(__file__).resolve().parent.parent
    +INSTALL_SH = REPO_ROOT / "scripts" / "install.sh"
    +INSTALL_PS1 = REPO_ROOT / "scripts" / "install.ps1"
    +
    +pytestmark = pytest.mark.skipif(
    +    shutil.which("git") is None or shutil.which("bash") is None,
    +    reason="needs git and bash",
    +)
    +
    +
    +def _git(cwd: Path, *args: str) -> None:
    +    subprocess.run(
    +        ["git", "-c", "user.email=t@t", "-c", "user.name=t", *args],
    +        cwd=cwd,
    +        check=True,
    +        capture_output=True,
    +    )
    +
    +
    +def _extract_no_commit_guard() -> str:
    +    """Pull the clone_repo() guard that drops a commit-less checkout."""
    +    text = INSTALL_SH.read_text()
    +    m = re.search(
    +        r'if \[ -d "\$INSTALL_DIR/\.git" \] && ! git -C "\$INSTALL_DIR" '
    +        r"rev-parse --verify HEAD.*?\n    fi",
    +        text,
    +        re.DOTALL,
    +    )
    +    assert m is not None, "no-commit guard not found in install.sh clone_repo()"
    +    return m.group(0)
    +
    +
    +def _run_guard(install_dir: Path) -> None:
    +    block = _extract_no_commit_guard()
    +    script = (
    +        "log_warn() { echo \"WARN: $*\"; }\n"
    +        f"INSTALL_DIR={shlex.quote(str(install_dir))}\n"
    +        f"{block}\n"
    +    )
    +    res = subprocess.run(["bash", "-c", script], capture_output=True, text=True)
    +    assert res.returncode == 0, res.stderr
    +
    +
    +def test_install_sh_guard_moves_commitless_checkout_aside(tmp_path: Path) -> None:
    +    install_dir = tmp_path / "hermes-agent"
    +    install_dir.mkdir()
    +    _git(install_dir, "init")
    +    (install_dir / "leftover.txt").write_text("partial download")  # untracked
    +
    +    # Sanity: this is exactly the state that breaks `git stash`.
    +    head = subprocess.run(
    +        ["git", "-C", str(install_dir), "rev-parse", "--verify", "HEAD"],
    +        capture_output=True,
    +    )
    +    assert head.returncode != 0
    +
    +    _run_guard(install_dir)
    +    # The original path is cleared so a fresh clone can proceed, but the
    +    # content is preserved in a backup (never deleted -- review feedback).
    +    assert not install_dir.exists(), "commit-less checkout should be moved aside"
    +    backups = list(install_dir.parent.glob(install_dir.name + ".broken-*"))
    +    assert len(backups) == 1, "broken checkout should be moved to one backup dir"
    +    assert (backups[0] / "leftover.txt").read_text() == "partial download"
    +
    +
    +def test_install_sh_guard_keeps_repo_with_commits(tmp_path: Path) -> None:
    +    install_dir = tmp_path / "hermes-agent"
    +    install_dir.mkdir()
    +    _git(install_dir, "init")
    +    (install_dir / "f.txt").write_text("real content")
    +    _git(install_dir, "add", "f.txt")
    +    _git(install_dir, "commit", "-m", "init")
    +
    +    _run_guard(install_dir)
    +    assert install_dir.exists()
    +    assert (install_dir / "f.txt").exists(), "a real checkout must be left intact"
    +    assert not list(install_dir.parent.glob(install_dir.name + ".broken-*")), (
    +        "a healthy checkout must not be moved aside"
    +    )
    +
    +
    +def test_install_sh_guard_ignores_non_repo_dir(tmp_path: Path) -> None:
    +    install_dir = tmp_path / "hermes-agent"
    +    install_dir.mkdir()
    +    (install_dir / "f.txt").write_text("not a repo")
    +
    +    _run_guard(install_dir)
    +    # No .git → not our concern; the existing "not a git repository" branch
    +    # still handles it. The guard must leave it untouched.
    +    assert install_dir.exists()
    +    assert (install_dir / "f.txt").exists()
    +
    +
    +def test_install_ps1_validity_requires_initial_commit() -> None:
    +    """The PowerShell repo-validity gate must also require a resolvable HEAD."""
    +    text = INSTALL_PS1.read_text()
    +    assert "rev-parse --verify HEAD" in text, (
    +        "install.ps1 must probe for an initial commit (#40998)"
    +    )
    +    # Contract: $repoValid is only set when the HEAD probe succeeded too.
    +    assert re.search(
    +        r"if \(\$revParseOk -and \$statusOk -and \$hasCommit\) \{",
    +        text,
    +    ), "repo validity must be gated on $hasCommit, not just rev-parse + status"
    +    # Cleanup must be non-destructive: move the broken checkout aside, never
    +    # `Remove-Item -Recurse -Force` it (review feedback on #40998).
    +    assert "Move-Item -LiteralPath $InstallDir" in text, (
    +        "install.ps1 must move an invalid checkout aside, not delete it"
    +    )
    +    assert "Remove-Item -Recurse -Force $InstallDir -ErrorAction Stop" not in text, (
    +        "the destructive wipe of an existing install dir must be gone "
    +        "(transient cleanup of a just-failed clone is fine)"
    +    )
    ```
- **#41208: fix(install): re-clone interrupted (commit-less) checkout instead of failing update**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#41941`, `#40998`

## Fix status

- Merged to main in PR #41941 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-06-08`.
- Subsystem: `hermes_cli`
