diff --git a/src/dcode/core.py b/src/dcode/core.py index 3c31126..c67e757 100644 --- a/src/dcode/core.py +++ b/src/dcode/core.py @@ -22,6 +22,25 @@ def _find_repo_root(start: Path) -> Path | None: return None +def _find_project_root(target: Path) -> Path | None: + """Walk *target* and its ancestors for a ``.git`` (file or dir). + + Returns the directory containing the ``.git`` entry, or ``None`` if no + such ancestor exists (target is outside any git repository / worktree). + Used so ``dcode shell`` and ``dcode `` work from a subdirectory + of a repo or worktree, not only from the root. + """ + current = target + while True: + git = current / ".git" + if git.is_file() or git.is_dir(): + return current + parent = current.parent + if parent == current: + return None + current = parent + + def resolve_worktree(target: Path) -> tuple[Path, Path] | None: """If *target* is a git worktree root, return ``(main_repo, rel_path)``. @@ -74,6 +93,54 @@ def resolve_worktree(target: Path) -> tuple[Path, Path] | None: return (main_repo, rel_path) +def resolve_target(target: Path) -> tuple[Path, Path]: + """Resolve *target* to ``(project_root, container_subdir)``. + + ``project_root`` is the host directory that owns the devcontainer — + for a plain repo it's the repo root; for a worktree it's the **main + repo** so all worktrees share one container. + + ``container_subdir`` is the path **relative to** the devcontainer's + ``workspaceFolder`` that ``target`` corresponds to inside the + container, so callers can open / set the working directory there. + Empty :class:`Path` (``Path('.')``) means *target* maps directly to + the workspace folder root. + + When *target* is outside any git repo / worktree, returns + ``(target, Path('.'))`` so the existing "no devcontainer" code path + keeps working unchanged. + """ + project_root = _find_project_root(target) + if project_root is None: + return (target, Path(".")) + + # Worktree: project_root is the worktree dir, with a `.git` *file* + # pointing back at /.git/worktrees/. Anchor the + # container at the main repo so worktrees share it. + if (project_root / ".git").is_file(): + worktree = resolve_worktree(project_root) + if worktree is not None: + main_repo, wt_rel = worktree + try: + inside_wt = target.relative_to(project_root) + except ValueError: # pragma: no cover - defensive + inside_wt = Path(".") + container_subdir = ( + wt_rel if inside_wt == Path(".") else wt_rel / inside_wt + ) + return (main_repo, container_subdir) + # `.git` file that isn't a real worktree (submodule, malformed, + # external worktree): fall back to treating project_root as the + # anchor directly. + + # Plain repo (`.git` is a directory) or unresolvable worktree pointer. + try: + container_subdir = target.relative_to(project_root) + except ValueError: # pragma: no cover - defensive + container_subdir = Path(".") + return (project_root, container_subdir) + + def find_devcontainer(target: Path) -> Path | None: """Find devcontainer.json in the target directory.""" candidates = [ @@ -140,15 +207,8 @@ def run_dcode(path: str, *, insiders: bool = False) -> None: editor = "code-insiders" if insiders else "code" target = Path(path).resolve() - # For worktrees, resolve the main repo so all worktrees share one container. - worktree = resolve_worktree(target) - if worktree is not None: - main_repo, rel_path = worktree - devcontainer = find_devcontainer(main_repo) - else: - main_repo = None - rel_path = None - devcontainer = find_devcontainer(target) + project_root, container_subdir = resolve_target(target) + devcontainer = find_devcontainer(project_root) if devcontainer is None: rc = _launch_editor([editor, str(target)], label=f"Launching {editor}...") @@ -156,13 +216,12 @@ def run_dcode(path: str, *, insiders: bool = False) -> None: sys.exit(rc) return - if main_repo is not None: - host_path = str(main_repo) - base_folder = get_workspace_folder(devcontainer, main_repo) - workspace_folder = f"{base_folder}/{rel_path.as_posix()}" + host_path = str(project_root) + base_folder = get_workspace_folder(devcontainer, project_root) + if container_subdir == Path("."): + workspace_folder = base_folder else: - host_path = str(target) - workspace_folder = get_workspace_folder(devcontainer, target) + workspace_folder = f"{base_folder}/{container_subdir.as_posix()}" if is_wsl(): uri = build_uri_wsl(host_path, workspace_folder) diff --git a/src/dcode/doctor.py b/src/dcode/doctor.py index aab5a7b..908e557 100644 --- a/src/dcode/doctor.py +++ b/src/dcode/doctor.py @@ -26,6 +26,7 @@ build_uri, find_devcontainer, get_workspace_folder, + resolve_target, resolve_worktree, ) from dcode.wsl import ( @@ -279,21 +280,17 @@ def check_wsl_executeInWSL_settings() -> list[CheckResult]: def check_devcontainer(target: Path) -> CheckResult: - worktree = resolve_worktree(target) - if worktree is not None: - main_repo, _ = worktree - devcontainer = find_devcontainer(main_repo) - if devcontainer: - return ("ok", f"devcontainer: {devcontainer}", None) + project_root, _ = resolve_target(target) + devcontainer = find_devcontainer(project_root) + if devcontainer: + return ("ok", f"devcontainer: {devcontainer}", None) + if project_root != target: return ( "warn", - f"devcontainer: none in main repo ({main_repo}) — " + f"devcontainer: none in project root ({project_root}) — " "dcode will fall back to opening the directory directly", "add .devcontainer/devcontainer.json to enable container support", ) - devcontainer = find_devcontainer(target) - if devcontainer: - return ("ok", f"devcontainer: {devcontainer}", None) return ( "warn", f"devcontainer: none found in {target} — " @@ -303,12 +300,8 @@ def check_devcontainer(target: Path) -> CheckResult: def check_devcontainer_parses(target: Path) -> CheckResult: - worktree = resolve_worktree(target) - if worktree is not None: - main_repo, _ = worktree - devcontainer = find_devcontainer(main_repo) - else: - devcontainer = find_devcontainer(target) + project_root, _ = resolve_target(target) + devcontainer = find_devcontainer(project_root) if devcontainer is None: return ("skip", "devcontainer.json: no file to parse", None) try: @@ -483,86 +476,80 @@ def _build_plan_renderable( editor = "code-insiders" extra_note = '(showing code-insiders plan; "code" not on PATH)' - worktree = resolve_worktree(target) - if worktree is not None: - main_repo, rel_path = worktree - devcontainer = find_devcontainer(main_repo) - else: - main_repo, rel_path = None, None - devcontainer = find_devcontainer(target) + project_root, container_subdir = resolve_target(target) + devcontainer = find_devcontainer(project_root) + is_worktree = ( + project_root != target and (project_root / ".git").is_dir() + ) or resolve_worktree(project_root) is not None + # `resolve_worktree` reports whether the project_root itself is a + # worktree pointer. The `is_worktree` flag drives only the cyan + # "Detected git worktree" hint. pieces: list[RenderableType] = [] if devcontainer is None: - if main_repo is not None: + if project_root != target: pieces.append( - Text(f"Detected git worktree (main repo: {main_repo}).", style="cyan") + Text( + f"Detected project root at {project_root}.", style="cyan" + ) ) pieces.append( Text( - f"No devcontainer found in main repo — would open {target} in " - f"{editor} directly (no container).", + f"No devcontainer found in project root — would open " + f"{target} in {editor} directly (no container).", overflow="fold", ) ) else: - git_file = target / ".git" - if git_file.is_file(): - pieces.append( - Text( - f"{target} looks like a worktree or submodule but cannot " - "be resolved.", - style="yellow", - overflow="fold", - ) - ) - pieces.append( - Text( - f"Would open {target} in {editor} directly without " - "shared-container support.", - overflow="fold", - ) - ) - else: - pieces.append( - Text( - f"No devcontainer found — would open {target} in {editor} " - "directly.", - overflow="fold", - ) + pieces.append( + Text( + f"No devcontainer found — would open {target} in {editor} " + "directly.", + overflow="fold", ) + ) if extra_note: pieces.append(Text(extra_note, style="dim")) return Group(*pieces) # Devcontainer branch rows: list[tuple[str, str]] = [] - if main_repo is not None: - host_path = str(main_repo) - base = get_workspace_folder(devcontainer, main_repo) - workspace_folder = f"{base}/{rel_path.as_posix()}" - pieces.append(Text("Detected git worktree.", style="cyan")) - pieces.append( - Text( - f"Would open the MAIN repo at {main_repo} so all worktrees share " - "one container.", - overflow="fold", - ) + host_path = str(project_root) + base = get_workspace_folder(devcontainer, project_root) + if container_subdir == Path("."): + workspace_folder = base + ws_detail = workspace_folder + else: + workspace_folder = f"{base}/{container_subdir.as_posix()}" + ws_detail = ( + f"{workspace_folder} (= {base} + /{container_subdir.as_posix()})" ) - rows.append(("editor", editor)) - rows.append(("host path", host_path)) - rows.append( - ( - "effective workspaceFolder", - f"{workspace_folder} (= {base} + /{rel_path.as_posix()})", + + if project_root != target: + if is_worktree: + pieces.append(Text("Detected git worktree.", style="cyan")) + pieces.append( + Text( + f"Would anchor at the main repo {project_root} so all " + "worktrees share one container.", + overflow="fold", + ) ) - ) - else: - host_path = str(target) - workspace_folder = get_workspace_folder(devcontainer, target) - rows.append(("editor", editor)) - rows.append(("host path", host_path)) - rows.append(("effective workspaceFolder", workspace_folder)) + else: + pieces.append( + Text(f"Detected project root at {project_root}.", style="cyan") + ) + pieces.append( + Text( + f"Would open {target} inside the project's container.", + overflow="fold", + ) + ) + + rows.append(("editor", editor)) + rows.append(("host path", host_path)) + rows.append(("effective workspaceFolder", ws_detail)) rows.append(("devcontainer config path", str(devcontainer))) diff --git a/src/dcode/shell.py b/src/dcode/shell.py index 562a083..98e6ba9 100644 --- a/src/dcode/shell.py +++ b/src/dcode/shell.py @@ -20,7 +20,7 @@ import json5 from dcode import devcontainer_cli -from dcode.core import find_devcontainer, get_workspace_folder, resolve_worktree +from dcode.core import find_devcontainer, get_workspace_folder, resolve_target from dcode.wsl import _wsl_to_windows_path, get_windows_vscode_settings_path, is_wsl _ContainerState = Literal[ @@ -660,12 +660,14 @@ def run_shell(path: str, *, insiders: bool, shell_override: str | None) -> int: """ target = Path(path).resolve() - worktree = resolve_worktree(target) - if worktree is not None: - main_repo, rel_path = worktree - else: - main_repo = target - rel_path = None + project_root, container_subdir = resolve_target(target) + # `main_repo` is kept as the local name for project_root so the rest + # of the file (error messages, find_container, build, label, etc.) + # reads naturally without a sweeping rename. + main_repo = project_root + rel_path: Path | None = ( + None if container_subdir == Path(".") else container_subdir + ) devcontainer_path = find_devcontainer(main_repo) if devcontainer_path is None: diff --git a/tests/test_core.py b/tests/test_core.py index 5af98e6..4a04b52 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -9,9 +9,11 @@ from conftest import _make_worktree from dcode.core import ( + _find_project_root, build_uri, find_devcontainer, get_workspace_folder, + resolve_target, resolve_worktree, ) @@ -346,3 +348,119 @@ def test_worktree_with_custom_workspace_folder(self, tmp_path): uri = mock_run.call_args[0][0][2] assert uri.endswith("/workspace/.worktrees/pr-34") + + +class TestFindProjectRoot: + def test_finds_git_dir_in_target(self, tmp_path): + (tmp_path / ".git").mkdir() + assert _find_project_root(tmp_path) == tmp_path + + def test_finds_git_file_in_target_worktree_root(self, tmp_path): + _, worktree = _make_worktree(tmp_path) + assert _find_project_root(worktree) == worktree + + def test_walks_up_from_repo_subdir(self, tmp_path): + (tmp_path / ".git").mkdir() + sub = tmp_path / "src" / "deep" + sub.mkdir(parents=True) + assert _find_project_root(sub) == tmp_path + + def test_walks_up_from_worktree_subdir(self, tmp_path): + _, worktree = _make_worktree(tmp_path) + sub = worktree / "src" / "deep" + sub.mkdir(parents=True) + assert _find_project_root(sub) == worktree + + def test_returns_none_when_no_git_anywhere(self, tmp_path): + sub = tmp_path / "a" / "b" / "c" + sub.mkdir(parents=True) + assert _find_project_root(sub) is None + + +class TestResolveTarget: + def test_plain_repo_root(self, tmp_path): + (tmp_path / ".git").mkdir() + root, sub = resolve_target(tmp_path) + assert root == tmp_path + assert sub == Path(".") + + def test_plain_repo_subdir(self, tmp_path): + (tmp_path / ".git").mkdir() + deep = tmp_path / "src" / "lib" + deep.mkdir(parents=True) + root, sub = resolve_target(deep) + assert root == tmp_path + assert sub == Path("src/lib") + + def test_worktree_root(self, tmp_path): + main_repo, worktree = _make_worktree(tmp_path) + root, sub = resolve_target(worktree) + assert root == main_repo + assert sub == Path(".worktrees/pr-34") + + def test_worktree_subdir(self, tmp_path): + main_repo, worktree = _make_worktree(tmp_path) + deep = worktree / "src" / "lib" + deep.mkdir(parents=True) + root, sub = resolve_target(deep) + assert root == main_repo + assert sub == Path(".worktrees/pr-34/src/lib") + + def test_non_repo_returns_target_unchanged(self, tmp_path): + sub = tmp_path / "no" / "git" / "here" + sub.mkdir(parents=True) + root, subdir = resolve_target(sub) + assert root == sub + assert subdir == Path(".") + + +class TestRunDcodeSubdir: + """Subdir-of-repo and subdir-of-worktree variants of run_dcode.""" + + def test_subdir_of_plain_repo_opens_inside_container(self, tmp_path): + (tmp_path / ".git").mkdir() + dc_dir = tmp_path / ".devcontainer" + dc_dir.mkdir() + (dc_dir / "devcontainer.json").write_text('{"name": "x"}') + deep = tmp_path / "pkg" / "module" + deep.mkdir(parents=True) + + with patch("dcode.core.subprocess.run", return_value=_ok()) as mock_run: + from dcode.core import run_dcode + run_dcode(str(deep), insiders=False) + + argv = mock_run.call_args[0][0] + assert argv[0] == "code" + assert argv[1] == "--folder-uri" + uri = argv[2] + assert uri.startswith("vscode-remote://dev-container+") + # workspace folder default is /workspaces/, then /pkg/module + assert uri.endswith(f"/workspaces/{tmp_path.name}/pkg/module") + + def test_subdir_of_worktree_opens_inside_shared_container(self, tmp_path): + main_repo, worktree = _make_worktree(tmp_path) + dc_dir = main_repo / ".devcontainer" + dc_dir.mkdir() + (dc_dir / "devcontainer.json").write_text('{"name": "x"}') + deep = worktree / "src" / "lib" + deep.mkdir(parents=True) + + with patch("dcode.core.subprocess.run", return_value=_ok()) as mock_run: + from dcode.core import run_dcode + run_dcode(str(deep)) + + uri = mock_run.call_args[0][0][2] + # main repo is the host path → /workspaces/ + # plus the worktree-relative path plus the subdir + assert uri.endswith( + f"/workspaces/{main_repo.name}/.worktrees/pr-34/src/lib" + ) + + def test_subdir_outside_repo_no_devcontainer_falls_back(self, tmp_path): + sub = tmp_path / "lonely" / "folder" + sub.mkdir(parents=True) + with patch("dcode.core.subprocess.run", return_value=_ok()) as mock_run: + from dcode.core import run_dcode + run_dcode(str(sub)) + argv = mock_run.call_args[0][0] + assert argv == ["code", str(sub)] diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 8e806ae..3f2b981 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -340,7 +340,7 @@ def test_worktree_no_devcontainer(self, tmp_path): _, worktree = _make_worktree(tmp_path) status, msg, _ = doctor.check_devcontainer(worktree) assert status == "warn" - assert "main repo" in msg + assert "project root" in msg class TestCheckDevcontainerParses: @@ -530,15 +530,17 @@ def test_with_worktree_and_devcontainer(self, tmp_path, capsys): with patch("dcode.doctor.is_wsl", return_value=False): doctor.render_plan(worktree, code_present=True, insiders_present=False) err = capsys.readouterr().err - assert "MAIN repo" in err + assert "main repo" in err assert "/work/.worktrees/pr-34" in err def test_external_worktree(self, tmp_path, capsys): + # A .git file that points at a non-existent gitdir reverts to plain + # behaviour: project_root == target, no devcontainer, plain open. (tmp_path / ".git").write_text("gitdir: /elsewhere/that/does/not/exist\n") with patch("dcode.doctor.is_wsl", return_value=False): doctor.render_plan(tmp_path, code_present=True, insiders_present=False) err = capsys.readouterr().err - assert "cannot be resolved" in err + assert "No devcontainer found" in err def test_wsl_shows_uri_and_settings(self, tmp_path, capsys): dc = tmp_path / ".devcontainer" diff --git a/tests/test_shell.py b/tests/test_shell.py index c0eeef6..0c631e0 100644 --- a/tests/test_shell.py +++ b/tests/test_shell.py @@ -934,6 +934,83 @@ def test_worktree_uses_main_repo_for_lookup(self, tmp_path): candidate = probe_mock.call_args.args[1] assert candidate == "/workspaces/main-repo/.worktrees/pr-34" + def test_worktree_subdir_uses_main_repo_and_deeper_workdir(self, tmp_path): + # Regression: `dcode shell` from inside a worktree subdir must + # resolve the main repo and point the container working directory + # at the deeper folder. Previously this failed with + # "no devcontainer.json found" because we only looked at the exact + # target dir for a `.git` pointer. + main_repo, worktree = _make_worktree(tmp_path) + (main_repo / ".devcontainer").mkdir() + (main_repo / ".devcontainer" / "devcontainer.json").write_text( + '{"workspaceFolder": "/workspaces/main-repo"}' + ) + deep = worktree / "src" / "lib" + deep.mkdir(parents=True) + + find_mock = MagicMock( + return_value=ContainerLookup(state="running", id="cid") + ) + probe_mock = MagicMock( + return_value="/workspaces/main-repo/.worktrees/pr-34/src/lib" + ) + with ( + patch("dcode.shell.find_container", find_mock), + patch("dcode.shell._inspect_container_metadata", return_value=[]), + patch("dcode.shell.find_ssh_socket", return_value=None), + patch("dcode.shell.probe_workdir", probe_mock), + patch("dcode.shell.resolve_terminal_profile", return_value=None), + patch("dcode.shell.detect_login_shell", return_value="/bin/sh"), + patch("dcode.shell.os.execvp"), + patch("sys.stdin") as stdin, + patch("sys.stdout") as stdout, + ): + stdin.isatty = MagicMock(return_value=True) + stdout.isatty = MagicMock(return_value=True) + run_shell(str(deep), insiders=False, shell_override=None) + + host_arg = find_mock.call_args.args[0] + assert host_arg == str(main_repo.resolve()) + + candidate = probe_mock.call_args.args[1] + assert candidate == "/workspaces/main-repo/.worktrees/pr-34/src/lib" + + def test_plain_repo_subdir_resolves_devcontainer_at_repo_root(self, tmp_path): + # `dcode shell` from a subdir of a plain (non-worktree) repo walks + # up to find the .git directory and the devcontainer there. + (tmp_path / ".git").mkdir() + (tmp_path / ".devcontainer").mkdir() + (tmp_path / ".devcontainer" / "devcontainer.json").write_text( + '{"workspaceFolder": "/workspaces/proj"}' + ) + deep = tmp_path / "pkg" / "module" + deep.mkdir(parents=True) + + find_mock = MagicMock( + return_value=ContainerLookup(state="running", id="cid") + ) + probe_mock = MagicMock(return_value="/workspaces/proj/pkg/module") + with ( + patch("dcode.shell.find_container", find_mock), + patch("dcode.shell._inspect_container_metadata", return_value=[]), + patch("dcode.shell.find_ssh_socket", return_value=None), + patch("dcode.shell.probe_workdir", probe_mock), + patch("dcode.shell.resolve_terminal_profile", return_value=None), + patch("dcode.shell.detect_login_shell", return_value="/bin/sh"), + patch("dcode.shell.os.execvp"), + patch("sys.stdin") as stdin, + patch("sys.stdout") as stdout, + ): + stdin.isatty = MagicMock(return_value=True) + stdout.isatty = MagicMock(return_value=True) + run_shell(str(deep), insiders=False, shell_override=None) + + host_arg = find_mock.call_args.args[0] + assert host_arg == str(tmp_path.resolve()) + + candidate = probe_mock.call_args.args[1] + assert candidate == "/workspaces/proj/pkg/module" + class TestRunShellStoppedPrompt: def _run_stopped(