Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 74 additions & 15 deletions src/dcode/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>`` 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)``.

Expand Down Expand Up @@ -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 <main_repo>/.git/worktrees/<name>. 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 = [
Expand Down Expand Up @@ -140,29 +207,21 @@ 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}...")
if rc:
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)
Expand Down
137 changes: 62 additions & 75 deletions src/dcode/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
build_uri,
find_devcontainer,
get_workspace_folder,
resolve_target,
resolve_worktree,
)
from dcode.wsl import (
Expand Down Expand Up @@ -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} — "
Expand All @@ -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:
Expand Down Expand Up @@ -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)))

Expand Down
16 changes: 9 additions & 7 deletions src/dcode/shell.py
Original file line number Diff line number Diff line change
Expand Up @@ -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[
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading