{"repo":"NousResearch/hermes-agent","public_id":"7458","display_label":"Checkpoint manager tilde path expansion bug","summary":"A bug where checkpoint_manager.py fails to expand tilde (~) in paths before resolving them, causing FileNotFoundError or malformed paths like /home/user/~/. Multiple fixes address this by calling expanduser() before resolve().","subsystem":"agent","counts":{"members":6,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":3,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":7959},"resolution":{"state":"partially_resolved","by":"pr-7959"},"freshness":{"state":"stale","last_verified_at":"2026-04-11T21:50:45+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.272},"fix_edges":[{"issue_number":7807,"pr_number":7959,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":7458,"title":"Bug: checkpoint_manager fails on paths containing `~` (tilde)","url":null,"state":"closed"},{"kind":"issue","number":7511,"title":"checkpoint_manager.py: Path.resolve() without expanduser() creates /home/user/~/ paths","url":null,"state":"closed"},{"kind":"issue","number":7807,"title":"Bug: checkpoint_manager.py fails with FileNotFoundError when working_dir contains unexpanded tilde (~)","url":null,"state":"closed"},{"kind":"pr","number":7496,"title":"fix(checkpoints): expand tilde in paths before resolving","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"90352b2ad","number":0},{"kind":"pr","value":"#7898","number":7898},{"kind":"pr","value":"#7807","number":7807}],"category":"merged_via","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":7898,"title":"fix: normalize checkpoint manager home-relative paths","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#7959","number":7959}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":7959,"title":"fix: normalize checkpoint manager home-relative paths","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tests/tools/test_checkpoint_manager.py","tools/checkpoint_manager.py"],"fix_hunks":[{"file":"tests/tools/test_checkpoint_manager.py","hunks":["@@ -1,9 +1,6 @@\n \"\"\"Tests for tools/checkpoint_manager.py — CheckpointManager.\"\"\"\n \n import logging\n-import os\n-import json\n-import shutil\n import subprocess\n import pytest\n from pathlib import Path","@@ -42,6 +39,19 @@ def checkpoint_base(tmp_path):\n     return tmp_path / \"checkpoints\"\n \n \n[redacted:email]()\n+def fake_home(tmp_path, monkeypatch):\n+    \"\"\"Set a deterministic fake home for expanduser/path-home behavior.\"\"\"\n+    home = tmp_path / \"home\"\n+    home.mkdir()\n+    monkeypatch.setenv(\"HOME\", str(home))\n+    monkeypatch.setenv(\"USERPROFILE\", str(home))\n+    monkeypatch.delenv(\"HOMEDRIVE\", raising=False)\n+    monkeypatch.delenv(\"HOMEPATH\", raising=False)\n+    monkeypatch.setattr(Path, \"home\", classmethod(lambda cls: home))\n+    return home\n+\n+\n @pytest.fixture()\n def mgr(work_dir, checkpoint_base, monkeypatch):\n     \"\"\"CheckpointManager with redirected checkpoint base.\"\"\"","@@ -78,6 +88,16 @@ def test_under_checkpoint_base(self, work_dir, checkpoint_base, monkeypatch):\n         p = _shadow_repo_path(str(work_dir))\n         assert str(p).startswith(str(checkpoint_base))\n \n+    def test_tilde_and_expanded_home_share_shadow_repo(self, fake_home, checkpoint_base, monkeypatch):\n+        monkeypatch.setattr(\"tools.checkpoint_manager.CHECKPOINT_BASE\", checkpoint_base)\n+        project = fake_home / \"project\"\n+        project.mkdir()\n+\n+        tilde_path = f\"~/{project.name}\"\n+        expanded_path = str(project)\n+\n+        assert _shadow_repo_path(tilde_path) == _shadow_repo_path(expanded_path)\n+\n \n # =========================================================================\n # Shadow repo init","@@ -221,6 +241,20 @@ def test_multiple_checkpoints_ordered(self, mgr, work_dir):\n         assert result[0][\"reason\"] == \"third\"\n         assert result[2][\"reason\"] == \"first\"\n \n+    def test_tilde_path_lists_same_checkpoints_as_expanded_path(self, checkpoint_base, fake_home, monkeypatch):\n+        monkeypatch.setattr(\"tools.checkpoint_manager.CHECKPOINT_BASE\", checkpoint_base)\n+        mgr = CheckpointManager(enabled=True, max_snapshots=50)\n+        project = fake_home / \"project\"\n+        project.mkdir()\n+        (project / \"main.py\").write_text(\"v1\\n\")\n+\n+        tilde_path = f\"~/{project.name}\"\n+        assert mgr.ensure_checkpoint(tilde_path, \"initial\") is True\n+\n+        listed = mgr.list_checkpoints(str(project))\n+        assert len(listed) == 1\n+        assert listed[0][\"reason\"] == \"initial\"\n+\n \n # =========================================================================\n # CheckpointManager — restoring","@@ -271,6 +305,28 @@ def test_restore_creates_pre_rollback_snapshot(self, mgr, work_dir):\n         assert len(all_cps) >= 2\n         assert \"pre-rollback\" in all_cps[0][\"reason\"]\n \n+    def test_tilde_path_supports_diff_and_restore_flow(self, checkpoint_base, fake_home, monkeypatch):\n+        monkeypatch.setattr(\"tools.checkpoint_manager.CHECKPOINT_BASE\", checkpoint_base)\n+        mgr = CheckpointManager(enabled=True, max_snapshots=50)\n+        project = fake_home / \"project\"\n+        project.mkdir()\n+        file_path = project / \"main.py\"\n+        file_path.write_text(\"original\\n\")\n+\n+        tilde_path = f\"~/{project.name}\"\n+        assert mgr.ensure_checkpoint(tilde_path, \"initial\") is True\n+        mgr.new_turn()\n+\n+        file_path.write_text(\"changed\\n\")\n+        checkpoints = mgr.list_checkpoints(str(project))\n+        diff_result = mgr.diff(tilde_path, checkpoints[0][\"hash\"])\n+        assert diff_result[\"success\"] is True\n+        assert \"main.py\" in diff_result[\"diff\"]\n+\n+        restore_result = mgr.restore(tilde_path, checkpoints[0][\"hash\"])\n+        assert restore_result[\"success\"] is True\n+        assert file_path.read_text() == \"original\\n\"\n+\n \n # =========================================================================\n # CheckpointManager — working dir resolution","@@ -310,6 +366,19 @@ def test_falls_back_to_parent(self, tmp_path):\n         result = mgr.get_working_dir_for_path(str(filepath))\n         assert result == str(filepath.parent)\n \n+    def test_resolves_tilde_path_to_project_root(self, fake_home):\n+        mgr = CheckpointManager(enabled=True)\n+        project = fake_home / \"myproject\"\n+        project.mkdir()\n+        (project / \"pyproject.toml\").write_text(\"[project]\\n\")\n+        subdir = project / \"src\"\n+        subdir.mkdir()\n+        filepath = subdir / \"main.py\"\n+        filepath.write_text(\"x\\n\")\n+\n+        result = mgr.get_working_dir_for_path(f\"~/{project.name}/src/main.py\")\n+        assert result == str(project)\n+\n \n # =========================================================================\n # Git env isolation","@@ -333,6 +402,14 @@ def test_clears_index_file(self, tmp_path, monkeypatch):\n         env = _git_env(shadow, str(tmp_path))\n         assert \"GIT_INDEX_FILE\" not in env\n \n+    def test_expands_tilde_in_work_tree(self, fake_home, tmp_path):\n+        shadow = tmp_path / \"shadow\"\n+        work = fake_home / \"work\"\n+        work.mkdir()\n+\n+        env = _git_env(shadow, f\"~/{work.name}\")\n+        assert env[\"GIT_WORK_TREE\"] == str(work.resolve())\n+\n \n # =========================================================================\n # format_checkpoint_list","@@ -384,6 +461,8 @@ def test_no_git_installed(self, work_dir, checkpoint_base, monkeypatch):\n         assert result is False\n \n     def test_run_git_allows_expected_nonzero_without_error_log(self, tmp_path, caplog):\n+        work = tmp_path / \"work\"\n+        work.mkdir()\n         completed = subprocess.CompletedProcess(\n             args=[\"git\", \"diff\", \"--cached\", \"--quiet\"],\n             returncode=1,","@@ -395,7 +474,7 @@ def test_run_git_allows_expected_nonzero_without_error_log(self, tmp_path, caplo\n                 ok, stdout, stderr = _run_git(\n                     [\"diff\", \"--cached\", \"--quiet\"],\n                     tmp_path / \"shadow\",\n-                    str(tmp_path / \"work\"),\n+                    str(work),\n                     allowed_returncodes={1},\n                 )\n         assert ok is False","@@ -403,6 +482,38 @@ def test_run_git_allows_expected_nonzero_without_error_log(self, tmp_path, caplo\n         assert stderr == \"\"\n         assert not caplog.records\n \n+    def test_run_git_invalid_working_dir_reports_path_error(self, tmp_path, caplog):\n+        missing = tmp_path / \"missing\"\n+        with caplog.at_level(logging.ERROR, logger=\"tools.checkpoint_manager\"):\n+            ok, stdout, stderr = _run_git(\n+                [\"status\"],\n+                tmp_path / \"shadow\",\n+                str(missing),\n+            )\n+        assert ok is False\n+        assert stdout == \"\"\n+        assert \"working directory not found\" in stderr\n+        assert not any(\"Git executable not found\" in r.getMessage() for r in caplog.records)\n+\n+    def test_run_git_missing_git_reports_git_not_found(self, tmp_path, monkeypatch, caplog):\n+        work = tmp_path / \"work\"\n+        work.mkdir()\n+\n+        def raise_missing_git(*args, **kwargs):\n+            raise FileNotFoundError(2, \"No such file or directory\", \"git\")\n+\n+        monkeypatch.setattr(\"tools.checkpoint_manager.subprocess.run\", raise_missing_git)\n+        with caplog.at_level(logging.ERROR, logger=\"tools.checkpoint_manager\"):\n+            ok, stdout, stderr = _run_git(\n+                [\"status\"],\n+                tmp_path / \"shadow\",\n+                str(work),\n+            )\n+        assert ok is False\n+        assert stdout == \"\"\n+        assert stderr == \"git not found\"\n+        assert any(\"Git executable not found\" in r.getMessage() for r in caplog.records)\n+\n     def test_checkpoint_failure_does_not_raise(self, mgr, work_dir, monkeypatch):\n         \"\"\"Checkpoint failures should never raise — they're silently logged.\"\"\"\n         def broken_run_git(*args, **kwargs):"]},{"file":"tools/checkpoint_manager.py","hunks":["@@ -100,7 +100,7 @@ def _validate_file_path(file_path: str, working_dir: str) -> Optional[str]:\n     if os.path.isabs(file_path):\n         return f\"File path must be relative, got absolute path: {file_path!r}\"\n     # Resolve and check containment within working_dir\n-    abs_workdir = Path(working_dir).resolve()\n+    abs_workdir = _normalize_path(working_dir)\n     resolved = (abs_workdir / file_path).resolve()\n     try:\n         resolved.relative_to(abs_workdir)","@@ -113,18 +113,24 @@ def _validate_file_path(file_path: str, working_dir: str) -> Optional[str]:\n # Shadow repo helpers\n # ---------------------------------------------------------------------------\n \n+def _normalize_path(path_value: str) -> Path:\n+    \"\"\"Return a canonical absolute path for checkpoint operations.\"\"\"\n+    return Path(path_value).expanduser().resolve()\n+\n+\n def _shadow_repo_path(working_dir: str) -> Path:\n     \"\"\"Deterministic shadow repo path: sha256(abs_path)[:16].\"\"\"\n-    abs_path = str(Path(working_dir).resolve())\n+    abs_path = str(_normalize_path(working_dir))\n     dir_hash = hashlib.sha256(abs_path.encode()).hexdigest()[:16]\n     return CHECKPOINT_BASE / dir_hash\n \n \n def _git_env(shadow_repo: Path, working_dir: str) -> dict:\n     \"\"\"Build env dict that redirects git to the shadow repo.\"\"\"\n+    normalized_working_dir = _normalize_path(working_dir)\n     env = os.environ.copy()\n     env[\"GIT_DIR\"] = str(shadow_repo)\n-    env[\"GIT_WORK_TREE\"] = str(Path(working_dir).resolve())\n+    env[\"GIT_WORK_TREE\"] = str(normalized_working_dir)\n     env.pop(\"GIT_INDEX_FILE\", None)\n     env.pop(\"GIT_NAMESPACE\", None)\n     env.pop(\"GIT_ALTERNATE_OBJECT_DIRECTORIES\", None)","@@ -144,7 +150,17 @@ def _run_git(\n     exits while preserving the normal ``ok = (returncode == 0)`` contract.\n     Example: ``git diff --cached --quiet`` returns 1 when changes exist.\n     \"\"\"\n-    env = _git_env(shadow_repo, working_dir)\n+    normalized_working_dir = _normalize_path(working_dir)\n+    if not normalized_working_dir.exists():\n+        msg = f\"working directory not found: {normalized_working_dir}\"\n+        logger.error(\"Git command skipped: %s (%s)\", \" \".join([\"git\"] + list(args)), msg)\n+        return False, \"\", msg\n+    if not normalized_working_dir.is_dir():\n+        msg = f\"working directory is not a directory: {normalized_working_dir}\"\n+        logger.error(\"Git command skipped: %s (%s)\", \" \".join([\"git\"] + list(args)), msg)\n+        return False, \"\", msg\n+\n+    env = _git_env(shadow_repo, str(normalized_working_dir))\n     cmd = [\"git\"] + list(args)\n     allowed_returncodes = allowed_returncodes or set()\n     try:","@@ -154,7 +170,7 @@ def _run_git(\n             text=True,\n             timeout=timeout,\n             env=env,\n-            cwd=str(Path(working_dir).resolve()),\n+            cwd=str(normalized_working_dir),\n         )\n         ok = result.returncode == 0\n         stdout = result.stdout.strip()","@@ -169,9 +185,14 @@ def _run_git(\n         msg = f\"git timed out after {timeout}s: {' '.join(cmd)}\"\n         logger.error(msg, exc_info=True)\n         return False, \"\", msg\n-    except FileNotFoundError:\n-        logger.error(\"Git executable not found: %s\", \" \".join(cmd), exc_info=True)\n-        return False, \"\", \"git not found\"\n+    except FileNotFoundError as exc:\n+        missing_target = getattr(exc, \"filename\", None)\n+        if missing_target == \"git\":\n+            logger.error(\"Git executable not found: %s\", \" \".join(cmd), exc_info=True)\n+            return False, \"\", \"git not found\"\n+        msg = f\"working directory not found: {normalized_working_dir}\"\n+        logger.error(\"Git command failed before execution: %s (%s)\", \" \".join(cmd), msg, exc_info=True)\n+        return False, \"\", msg\n     except Exception as exc:\n         logger.error(\"Unexpected git error running %s: %s\", \" \".join(cmd), exc, exc_info=True)\n         return False, \"\", str(exc)","@@ -198,7 +219,7 @@ def _init_shadow_repo(shadow_repo: Path, working_dir: str) -> Optional[str]:\n     )\n \n     (shadow_repo / \"HERMES_WORKDIR\").write_text(\n-        str(Path(working_dir).resolve()) + \"\\n\", encoding=\"utf-8\"\n+        str(_normalize_path(working_dir)) + \"\\n\", encoding=\"utf-8\"\n     )\n \n     logger.debug(\"Initialised checkpoint repo at %s for %s\", shadow_repo, working_dir)","@@ -273,7 +294,7 @@ def ensure_checkpoint(self, working_dir: str, reason: str = \"auto\") -> bool:\n         if not self._git_available:\n             return False\n \n-        abs_dir = str(Path(working_dir).resolve())\n+        abs_dir = str(_normalize_path(working_dir))\n \n         # Skip root, home, and other overly broad directories\n         if abs_dir in (\"/\", str(Path.home())):","@@ -298,7 +319,7 @@ def list_checkpoints(self, working_dir: str) -> List[Dict]:\n         Returns a list of dicts with keys: hash, short_hash, timestamp, reason,\n         files_changed, insertions, deletions.  Most recent first.\n         \"\"\"\n-        abs_dir = str(Path(working_dir).resolve())\n+        abs_dir = str(_normalize_path(working_dir))\n         shadow = _shadow_repo_path(abs_dir)\n \n         if not (shadow / \"HEAD\").exists():","@@ -360,7 +381,7 @@ def diff(self, working_dir: str, commit_hash: str) -> Dict:\n         if hash_err:\n             return {\"success\": False, \"error\": hash_err}\n \n-        abs_dir = str(Path(working_dir).resolve())\n+        abs_dir = str(_normalize_path(working_dir))\n         shadow = _shadow_repo_path(abs_dir)\n \n         if not (shadow / \"HEAD\").exists():","@@ -418,7 +439,7 @@ def restore(self, working_dir: str, commit_hash: str, file_path: str = None) ->\n         if hash_err:\n             return {\"success\": False, \"error\": hash_err}\n \n-        abs_dir = str(Path(working_dir).resolve())\n+        abs_dir = str(_normalize_path(working_dir))\n \n         # Validate file_path to prevent path traversal outside the working dir\n         if file_path:","@@ -474,7 +495,7 @@ def get_working_dir_for_path(self, file_path: str) -> str:\n         (directory containing .git, pyproject.toml, package.json, etc.).\n         Falls back to the file's parent directory.\n         \"\"\"\n-        path = Path(file_path).resolve()\n+        path = _normalize_path(file_path)\n         if path.is_dir():\n             candidate = path\n         else:\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}