Skip to content

Commit c6b39fe

Browse files
author
Bob Yang
committed
fix: honor worktree config in TUI launches
1 parent b31b0b9 commit c6b39fe

5 files changed

Lines changed: 254 additions & 10 deletions

File tree

cli.py

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1247,6 +1247,34 @@ def _path_is_within_root(path: Path, root: Path) -> bool:
12471247
return False
12481248

12491249

1250+
def _worktree_slug(name: str, *, fallback: str = "session", max_len: int = 40) -> str:
1251+
"""Return a filesystem/git-ref-safe slug for a human workspace name."""
1252+
slug = re.sub(r"[^a-z0-9._-]+", "-", str(name or "").strip().lower())
1253+
slug = re.sub(r"[-_.]{2,}", "-", slug).strip("-_.")
1254+
slug = slug[:max_len].strip("-_.")
1255+
return slug or fallback
1256+
1257+
1258+
def _workspace_name_for_worktree(repo_root: str) -> str:
1259+
"""Best-effort human workspace label for naming auto-created worktrees.
1260+
1261+
Prefer an explicit Hermes Project that owns the repo; otherwise fall back to
1262+
the repository directory name. This keeps bare ``hermes -w`` / configured
1263+
worktree launches human-readable without adding a new user-facing setting.
1264+
"""
1265+
try:
1266+
from hermes_cli import projects_db as _projects_db
1267+
1268+
if _projects_db.projects_db_path().exists():
1269+
with _projects_db.connect_closing() as conn:
1270+
project = _projects_db.project_for_path(conn, repo_root)
1271+
if project is not None:
1272+
return project.slug or project.name
1273+
except Exception as exc:
1274+
logger.debug("worktree workspace-name project lookup failed: %s", exc)
1275+
return Path(repo_root).name or "session"
1276+
1277+
12501278
def _resolve_worktree_base(repo_root: str) -> tuple:
12511279
"""Resolve the freshest base ref to branch a new worktree from.
12521280

@@ -1322,7 +1350,11 @@ def _git(args, timeout=20):
13221350
return "HEAD", "HEAD (local — could not reach remote)"
13231351

13241352

1325-
def _setup_worktree(repo_root: str = None, sync_base: bool = True) -> Optional[Dict[str, str]]:
1353+
def _setup_worktree(
1354+
repo_root: Optional[str] = None,
1355+
sync_base: bool = True,
1356+
workspace_name: Optional[str] = None,
1357+
) -> Optional[Dict[str, str]]:
13261358
"""Create an isolated git worktree for this CLI session.
13271359

13281360
Returns a dict with worktree metadata on success, None on failure.
@@ -1342,7 +1374,8 @@ def _setup_worktree(repo_root: str = None, sync_base: bool = True) -> Optional[D
13421374
return None
13431375

13441376
short_id = uuid.uuid4().hex[:8]
1345-
wt_name = f"hermes-{short_id}"
1377+
workspace_slug = _worktree_slug(workspace_name or _workspace_name_for_worktree(repo_root))
1378+
wt_name = f"hermes-{workspace_slug}-{short_id}"
13461379
branch_name = f"hermes/{wt_name}"
13471380

13481381
worktrees_dir = Path(repo_root) / ".worktrees"
@@ -1481,6 +1514,7 @@ def _setup_worktree(repo_root: str = None, sync_base: bool = True) -> Optional[D
14811514
"branch": branch_name,
14821515
"repo_root": repo_root,
14831516
"base": base_ref,
1517+
"workspace_slug": workspace_slug,
14841518
}
14851519

14861520
print(f"\033[32m✓ Worktree created:\033[0m {wt_path}")
@@ -15269,7 +15303,13 @@ def main(
1526915303
# ── Git worktree isolation (#652) ──
1527015304
# Create an isolated worktree so this agent instance doesn't collide
1527115305
# with other agents working on the same repo.
15272-
use_worktree = worktree or w or CLI_CONFIG.get("worktree", False)
15306+
from hermes_cli.config import resolve_worktree_options
15307+
15308+
use_worktree, _sync_base = resolve_worktree_options(
15309+
CLI_CONFIG,
15310+
explicit_worktree=worktree,
15311+
short_flag=w,
15312+
)
1527315313
wt_info = None
1527415314
if use_worktree:
1527515315
# Prune stale worktrees from crashed/killed sessions
@@ -15279,8 +15319,7 @@ def main(
1527915319
# Branch the worktree from the freshly-fetched remote tip by
1528015320
# default so it starts current with the project. Opt out with
1528115321
# worktree_sync: false to branch from local HEAD instead.
15282-
_sync_base = CLI_CONFIG.get("worktree_sync", True)
15283-
wt_info = _setup_worktree(sync_base=_sync_base)
15322+
wt_info = _setup_worktree(repo_root=_repo, sync_base=_sync_base)
1528415323
if wt_info:
1528515324
_active_worktree = wt_info
1528615325
os.environ["TERMINAL_CWD"] = wt_info["path"]

hermes_cli/config.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6035,6 +6035,21 @@ def load_config_readonly() -> Dict[str, Any]:
60356035
return _load_config_impl(want_deepcopy=False)
60366036

60376037

6038+
def resolve_worktree_options(
6039+
config: Optional[Dict[str, Any]] = None,
6040+
*,
6041+
explicit_worktree: bool = False,
6042+
short_flag: bool = False,
6043+
) -> tuple[bool, bool]:
6044+
"""Resolve worktree enablement and base-sync behavior from flags + config."""
6045+
cfg = config if isinstance(config, dict) else load_config_readonly()
6046+
use_worktree = bool(
6047+
explicit_worktree or short_flag or cfg.get("worktree", False)
6048+
)
6049+
sync_base = bool(cfg.get("worktree_sync", True))
6050+
return use_worktree, sync_base
6051+
6052+
60386053
def write_platform_config_field(
60396054
platform_key: str,
60406055
field_key: str,

hermes_cli/main.py

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1973,6 +1973,25 @@ def _resolve_tui_heap_mb(default_mb: int = 8192) -> int:
19731973
return max(1536, sized) if limit_mb > 2048 else sized
19741974

19751975

1976+
def _resolve_tui_worktree_options(
1977+
explicit_worktree: bool = False,
1978+
config: Optional[dict] = None,
1979+
) -> tuple[bool, bool]:
1980+
"""Return ``(use_worktree, sync_base)`` for a TUI launch.
1981+
1982+
Classic CLI worktree setup already honors the persistent ``worktree`` and
1983+
``worktree_sync`` config keys. TUI startup needs the same decision in the
1984+
Python wrapper before spawning Node, because the child receives its working
1985+
directory via ``HERMES_CWD`` / ``TERMINAL_CWD``.
1986+
"""
1987+
try:
1988+
from hermes_cli.config import resolve_worktree_options
1989+
1990+
return resolve_worktree_options(config, explicit_worktree=explicit_worktree)
1991+
except Exception:
1992+
return bool(explicit_worktree), True
1993+
1994+
19761995
def _launch_tui(
19771996
resume_session_id: Optional[str] = None,
19781997
tui_dev: bool = False,
@@ -1996,9 +2015,12 @@ def _launch_tui(
19962015
import tempfile
19972016

19982017
env = os.environ.copy()
2018+
config = None
19992019
try:
2000-
from hermes_cli.config import apply_terminal_config_to_env
2001-
apply_terminal_config_to_env(env=env)
2020+
from hermes_cli.config import apply_terminal_config_to_env, load_config_readonly
2021+
2022+
config = load_config_readonly() or {}
2023+
apply_terminal_config_to_env(env=env, config=config)
20022024
except Exception:
20032025
logger.debug("Failed to apply terminal config bridge for TUI launch", exc_info=True)
20042026
active_session_fd, active_session_file = tempfile.mkstemp(
@@ -2014,7 +2036,8 @@ def _launch_tui(
20142036
env.setdefault("NODE_ENV", "development" if tui_dev else "production")
20152037

20162038
wt_info = None
2017-
if worktree:
2039+
use_worktree, sync_base = _resolve_tui_worktree_options(worktree, config=config)
2040+
if use_worktree:
20182041
try:
20192042
from cli import (
20202043
_cleanup_worktree,
@@ -2026,7 +2049,7 @@ def _launch_tui(
20262049
repo = _git_repo_root()
20272050
if repo:
20282051
_prune_stale_worktrees(repo)
2029-
wt_info = _setup_worktree()
2052+
wt_info = _setup_worktree(repo_root=repo, sync_base=sync_base)
20302053
except Exception as exc:
20312054
print(f"✗ Failed to create TUI worktree: {exc}", file=sys.stderr)
20322055
wt_info = None
@@ -2102,9 +2125,9 @@ def _launch_tui(
21022125
if resume_session_id:
21032126
env["HERMES_TUI_RESUME"] = resume_session_id
21042127

2105-
argv, cwd = _make_tui_argv(tui_dir, tui_dev)
21062128
code: Optional[int] = None
21072129
try:
2130+
argv, cwd = _make_tui_argv(tui_dir, tui_dev)
21082131
try:
21092132
code = subprocess.call(argv, cwd=str(cwd), env=env)
21102133
except KeyboardInterrupt:

tests/cli/test_worktree_sync_base.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,3 +122,55 @@ def test_default_is_sync_true(self, remote_and_clone):
122122
info = cli._setup_worktree(str(clone))
123123
assert info is not None
124124
assert _head(info["path"]) == remote_head
125+
126+
def test_workspace_name_is_reflected_in_worktree_and_branch(self, remote_and_clone):
127+
clone, _, _ = remote_and_clone
128+
info = cli._setup_worktree(
129+
str(clone), sync_base=False, workspace_name="JARVIS Workspace!"
130+
)
131+
assert info is not None
132+
133+
leaf = Path(info["path"]).name
134+
assert leaf.startswith("hermes-jarvis-workspace-")
135+
assert info["branch"].startswith("hermes/hermes-jarvis-workspace-")
136+
assert info["workspace_slug"] == "jarvis-workspace"
137+
138+
def test_workspace_name_falls_back_to_repo_directory(self, remote_and_clone):
139+
clone, _, _ = remote_and_clone
140+
info = cli._setup_worktree(str(clone), sync_base=False)
141+
assert info is not None
142+
143+
leaf = Path(info["path"]).name
144+
assert leaf.startswith("hermes-clone-")
145+
assert info["branch"].startswith("hermes/hermes-clone-")
146+
assert info["workspace_slug"] == "clone"
147+
148+
def test_workspace_name_lookup_does_not_create_projects_db(self, remote_and_clone):
149+
from hermes_cli import projects_db
150+
151+
clone, _, _ = remote_and_clone
152+
db_path = projects_db.projects_db_path()
153+
assert not db_path.exists()
154+
155+
assert cli._workspace_name_for_worktree(str(clone)) == "clone"
156+
assert not db_path.exists()
157+
158+
def test_workspace_name_uses_owning_project_slug(self, remote_and_clone):
159+
from hermes_cli import projects_db
160+
161+
clone, _, _ = remote_and_clone
162+
with projects_db.connect_closing() as conn:
163+
projects_db.create_project(
164+
conn,
165+
name="Bob Workspace",
166+
slug="bob-workspace",
167+
primary_path=str(clone),
168+
)
169+
170+
info = cli._setup_worktree(str(clone), sync_base=False)
171+
assert info is not None
172+
173+
leaf = Path(info["path"]).name
174+
assert leaf.startswith("hermes-bob-workspace-")
175+
assert info["branch"].startswith("hermes/hermes-bob-workspace-")
176+
assert info["workspace_slug"] == "bob-workspace"

tests/hermes_cli/test_tui_resume_flow.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -962,6 +962,121 @@ def test_launch_tui_applies_terminal_backend_config(
962962
assert captured["env"]["TERMINAL_DOCKER_EXTRA_ARGS"] == '["--network=host"]'
963963

964964

965+
def test_resolve_tui_worktree_options_uses_persistent_config(main_mod):
966+
config = {"worktree": True, "worktree_sync": False}
967+
968+
assert main_mod._resolve_tui_worktree_options(
969+
explicit_worktree=False, config=config
970+
) == (
971+
True,
972+
False,
973+
)
974+
assert main_mod._resolve_tui_worktree_options(
975+
explicit_worktree=True, config=config
976+
) == (
977+
True,
978+
False,
979+
)
980+
981+
982+
def test_launch_tui_creates_worktree_from_persistent_config(monkeypatch, main_mod):
983+
import cli as cli_mod
984+
import hermes_cli.config as config_mod
985+
986+
captured = {}
987+
wt_info = {
988+
"path": "/repo/.worktrees/hermes-jarvis-deadbeef",
989+
"branch": "hermes/hermes-jarvis-deadbeef",
990+
"repo_root": "/repo",
991+
}
992+
993+
monkeypatch.setattr(
994+
config_mod,
995+
"load_config_readonly",
996+
lambda: {"worktree": True, "worktree_sync": False},
997+
)
998+
monkeypatch.setattr(
999+
config_mod, "apply_terminal_config_to_env", lambda **_kwargs: None
1000+
)
1001+
monkeypatch.setattr(
1002+
main_mod,
1003+
"_make_tui_argv",
1004+
lambda tui_dir, tui_dev: (["node", "dist/entry.js"], Path(".")),
1005+
)
1006+
monkeypatch.setattr(cli_mod, "_git_repo_root", lambda: "/repo")
1007+
monkeypatch.setattr(
1008+
cli_mod,
1009+
"_prune_stale_worktrees",
1010+
lambda repo: captured.update({"pruned_repo": repo}),
1011+
)
1012+
1013+
def fake_setup_worktree(*, repo_root=None, sync_base=True):
1014+
captured["repo_root"] = repo_root
1015+
captured["sync_base"] = sync_base
1016+
return wt_info
1017+
1018+
monkeypatch.setattr(cli_mod, "_setup_worktree", fake_setup_worktree)
1019+
monkeypatch.setattr(
1020+
cli_mod,
1021+
"_cleanup_worktree",
1022+
lambda info: captured.update({"cleaned": info}),
1023+
)
1024+
monkeypatch.setattr(
1025+
main_mod.subprocess,
1026+
"call",
1027+
lambda argv, cwd=None, env=None: captured.update({"env": env}) or 1,
1028+
)
1029+
1030+
with pytest.raises(SystemExit):
1031+
main_mod._launch_tui(worktree=False)
1032+
1033+
assert captured["pruned_repo"] == "/repo"
1034+
assert captured["repo_root"] == "/repo"
1035+
assert captured["sync_base"] is False
1036+
assert captured["env"]["HERMES_CWD"] == wt_info["path"]
1037+
assert captured["env"]["TERMINAL_CWD"] == wt_info["path"]
1038+
assert captured["cleaned"] is wt_info
1039+
1040+
1041+
def test_launch_tui_cleans_worktree_when_tui_argv_setup_fails(monkeypatch, main_mod):
1042+
import cli as cli_mod
1043+
import hermes_cli.config as config_mod
1044+
1045+
captured = {}
1046+
wt_info = {
1047+
"path": "/repo/.worktrees/hermes-jarvis-deadbeef",
1048+
"branch": "hermes/hermes-jarvis-deadbeef",
1049+
"repo_root": "/repo",
1050+
}
1051+
1052+
monkeypatch.setattr(
1053+
config_mod,
1054+
"load_config_readonly",
1055+
lambda: {"worktree": True, "worktree_sync": False},
1056+
)
1057+
monkeypatch.setattr(
1058+
config_mod, "apply_terminal_config_to_env", lambda **_kwargs: None
1059+
)
1060+
monkeypatch.setattr(cli_mod, "_git_repo_root", lambda: "/repo")
1061+
monkeypatch.setattr(cli_mod, "_prune_stale_worktrees", lambda repo: None)
1062+
monkeypatch.setattr(cli_mod, "_setup_worktree", lambda **_kwargs: wt_info)
1063+
monkeypatch.setattr(
1064+
cli_mod,
1065+
"_cleanup_worktree",
1066+
lambda info: captured.update({"cleaned": info}),
1067+
)
1068+
monkeypatch.setattr(
1069+
main_mod,
1070+
"_make_tui_argv",
1071+
lambda tui_dir, tui_dev: (_ for _ in ()).throw(RuntimeError("missing node")),
1072+
)
1073+
1074+
with pytest.raises(RuntimeError, match="missing node"):
1075+
main_mod._launch_tui(worktree=False)
1076+
1077+
assert captured["cleaned"] is wt_info
1078+
1079+
9651080
def test_launch_tui_exit_code_42_relaunches_update(monkeypatch, main_mod):
9661081
from unittest.mock import patch
9671082

0 commit comments

Comments
 (0)