Skip to content

Commit 78ce6fe

Browse files
misteriaudclaude
andcommitted
Fix git worktree detection for dda env dev start
Previously, `dda env dev start` failed when run from git worktrees with arbitrary directory names because it assumed repository directories were named exactly as the repository (e.g., "datadog-agent"). This commit adds git-aware repository resolution that: - Detects git repositories using remote URL, working for both regular repos and worktrees regardless of directory name - Maintains full backward compatibility with non-git directory lookup - Handles edge cases: missing git, no remote, nested structures Implementation: - Added `_resolve_repository_path()` with multi-strategy resolution - Added `_is_matching_repository()` using git remote URL detection - Updated repository mounting to use new git-aware resolver - Added comprehensive tests for worktree scenarios Fixes issue where worktrees like `.worktrees/my_feature` would fail with "Local repository not found: datadog-agent" Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
1 parent 911d691 commit 78ce6fe

File tree

2 files changed

+268
-7
lines changed

2 files changed

+268
-7
lines changed

src/dda/env/dev/types/linux_container.py

Lines changed: 76 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -187,15 +187,9 @@ def start(self) -> None:
187187
command.extend(("--mount", mount.as_csv()))
188188

189189
if not self.config.clone:
190-
from dda.utils.fs import Path
191-
192-
repos_path = Path.cwd().parent
193190
for repo_spec in self.config.repos:
194191
repo = repo_spec.split("@")[0]
195-
repo_path = repos_path / repo
196-
if not repo_path.is_dir():
197-
self.app.abort(f"Local repository not found: {repo}")
198-
192+
repo_path = self._resolve_repository_path(repo_spec)
199193
command.extend(("-v", f"{repo_path}:{self.repo_path(repo)}"))
200194

201195
for mount_spec in self.config.extra_mount_specs:
@@ -450,6 +444,81 @@ def repo_path(self, repo: str | None) -> str:
450444

451445
return f"{self.home_dir}/repos/{repo}"
452446

447+
def _resolve_repository_path(self, repo_spec: str) -> Path:
448+
"""
449+
Resolve the local path for a repository specification.
450+
451+
Tries multiple strategies:
452+
1. Check if current directory is the requested repo (git-aware, handles worktrees)
453+
2. Check parent directory for repo (backward compatible)
454+
455+
Args:
456+
repo_spec: Repository specification (e.g., "datadog-agent" or "datadog-agent@branch")
457+
458+
Returns:
459+
Path to the repository
460+
461+
Raises:
462+
Aborts if repository cannot be found
463+
"""
464+
from dda.utils.fs import Path
465+
466+
repo_name = repo_spec.split("@")[0] # Strip @branch/@tag if present
467+
468+
# Strategy 1: Check if current directory is a git repository matching the repo name
469+
cwd = Path.cwd()
470+
if self._is_matching_repository(cwd, repo_name):
471+
return cwd
472+
473+
# Strategy 2: Check parent directory (existing behavior, backward compatible)
474+
parent_repo_path = cwd.parent / repo_name
475+
if parent_repo_path.is_dir():
476+
if self._is_matching_repository(parent_repo_path, repo_name):
477+
return parent_repo_path
478+
# Fallback: If not a git repo but directory exists, use it for backward compat
479+
return parent_repo_path
480+
481+
self.app.abort(f"Local repository not found: {repo_name}")
482+
483+
def _is_matching_repository(self, path: Path, expected_repo_name: str) -> bool:
484+
"""
485+
Check if the given path is a git repository matching the expected repository name.
486+
487+
Uses git remote URL to determine the repository name, which works for:
488+
- Regular repositories
489+
- Git worktrees (regardless of directory name)
490+
- Nested repository structures
491+
492+
Args:
493+
path: Path to check
494+
expected_repo_name: Expected repository name (e.g., "datadog-agent")
495+
496+
Returns:
497+
True if the path is a git repository matching the expected name
498+
"""
499+
if not path.is_dir():
500+
return False
501+
502+
git_dir = path / ".git"
503+
if not git_dir.exists():
504+
return False
505+
506+
# Use git to get the repository name from the remote URL
507+
try:
508+
# Change to the target directory temporarily to get its remote
509+
import os
510+
511+
original_cwd = os.getcwd()
512+
try:
513+
os.chdir(path)
514+
remote = self.app.tools.git.get_remote()
515+
return remote.repo == expected_repo_name
516+
finally:
517+
os.chdir(original_cwd)
518+
except Exception:
519+
# Not a git repository or no remote configured
520+
return False
521+
453522
def _container_cp(self, source: str, destination: str, *args: Any) -> None:
454523
"""Runs a `cp -r` command inside the context of the container"""
455524
self.run_command(["cp", "-r", f'"{source}"', f'"{destination}"', *args])

tests/env/dev/types/test_linux_container.py

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1415,6 +1415,198 @@ def test_directory_to_existing_file_fails(self, linux_container, test_target_dir
14151415
linux_container.export_path(source="/folder1", destination=existing_file)
14161416

14171417

1418+
class TestRepositoryResolution:
1419+
"""Tests for git-aware repository resolution logic."""
1420+
1421+
def test_git_worktree_detection(self, dda, helpers, mocker, temp_dir, host_user_args):
1422+
"""Test that repositories are detected in git worktrees with arbitrary directory names."""
1423+
# Create a git repository structure
1424+
repos_dir = temp_dir / "repos"
1425+
repos_dir.ensure_dir()
1426+
main_repo_dir = repos_dir / "datadog-agent"
1427+
main_repo_dir.ensure_dir()
1428+
1429+
# Create a worktree with a different name
1430+
worktrees_dir = main_repo_dir / ".worktrees"
1431+
worktrees_dir.ensure_dir()
1432+
worktree_dir = worktrees_dir / "my_feature_branch"
1433+
worktree_dir.ensure_dir()
1434+
1435+
# Create a .git file (worktree indicator)
1436+
git_file = worktree_dir / ".git"
1437+
git_file.write_text("gitdir: /path/to/main/.git/worktrees/my_feature_branch")
1438+
1439+
# Mock git remote detection to return the correct repository name
1440+
mock_remote = mocker.MagicMock()
1441+
mock_remote.repo = "datadog-agent"
1442+
mocker.patch("dda.tools.git.Git.get_remote", return_value=mock_remote)
1443+
1444+
write_server_config = mocker.patch("dda.utils.ssh.write_server_config")
1445+
with (
1446+
worktree_dir.as_cwd(),
1447+
helpers.hybrid_patch(
1448+
"subprocess.run",
1449+
return_values={
1450+
# Start command checks the status
1451+
1: CompletedProcess([], returncode=0, stdout="{}"),
1452+
# Start method checks the status
1453+
2: CompletedProcess([], returncode=0, stdout="{}"),
1454+
# Capture image pull
1455+
# Capture container run
1456+
# Readiness check
1457+
5: CompletedProcess([], returncode=0, stdout="Server listening on :: port 22"),
1458+
},
1459+
) as calls,
1460+
):
1461+
result = dda("env", "dev", "start")
1462+
1463+
result.check(
1464+
exit_code=0,
1465+
output=helpers.dedent(
1466+
"""
1467+
Pulling image: datadog/agent-dev-env-linux
1468+
Creating and starting container: dda-linux-container-default
1469+
Waiting for container: dda-linux-container-default
1470+
"""
1471+
),
1472+
)
1473+
1474+
assert_ssh_config_written(write_server_config, "localhost")
1475+
1476+
# Verify that the worktree directory (not parent/datadog-agent) is mounted
1477+
shared_dir = temp_dir / "data" / "env" / "dev" / "linux-container" / "default" / ".shared"
1478+
global_shared_dir = shared_dir.parent.parent / ".shared"
1479+
starship_mount = get_starship_mount(global_shared_dir)
1480+
cache_volumes = get_cache_volumes()
1481+
1482+
# Check that the docker run command includes the worktree directory
1483+
docker_run_call = calls[1]
1484+
assert "-v" in docker_run_call[0][0]
1485+
mount_arg_idx = docker_run_call[0][0].index("-v")
1486+
# Find the repo mount (should be the worktree directory)
1487+
repo_mounts = [
1488+
docker_run_call[0][0][i+1]
1489+
for i, arg in enumerate(docker_run_call[0][0])
1490+
if arg == "-v" and ":/root/repos/datadog-agent" in docker_run_call[0][0][i+1]
1491+
]
1492+
assert len(repo_mounts) == 1
1493+
assert str(worktree_dir) in repo_mounts[0]
1494+
1495+
def test_current_directory_is_repo(self, dda, helpers, mocker, temp_dir, host_user_args):
1496+
"""Test that the current directory is used if it's a matching git repository."""
1497+
repos_dir = temp_dir / "repos"
1498+
repos_dir.ensure_dir()
1499+
repo_dir = repos_dir / "datadog-agent"
1500+
repo_dir.ensure_dir()
1501+
1502+
# Create a .git directory
1503+
git_dir = repo_dir / ".git"
1504+
git_dir.ensure_dir()
1505+
1506+
# Mock git remote detection
1507+
mock_remote = mocker.MagicMock()
1508+
mock_remote.repo = "datadog-agent"
1509+
mocker.patch("dda.tools.git.Git.get_remote", return_value=mock_remote)
1510+
1511+
write_server_config = mocker.patch("dda.utils.ssh.write_server_config")
1512+
with (
1513+
repo_dir.as_cwd(),
1514+
helpers.hybrid_patch(
1515+
"subprocess.run",
1516+
return_values={
1517+
# Start command checks the status
1518+
1: CompletedProcess([], returncode=0, stdout="{}"),
1519+
# Start method checks the status
1520+
2: CompletedProcess([], returncode=0, stdout="{}"),
1521+
# Capture image pull
1522+
# Capture container run
1523+
# Readiness check
1524+
5: CompletedProcess([], returncode=0, stdout="Server listening on :: port 22"),
1525+
},
1526+
) as calls,
1527+
):
1528+
result = dda("env", "dev", "start")
1529+
1530+
result.check(exit_code=0)
1531+
1532+
# Verify the current directory is used
1533+
docker_run_call = calls[1]
1534+
repo_mounts = [
1535+
docker_run_call[0][0][i+1]
1536+
for i, arg in enumerate(docker_run_call[0][0])
1537+
if arg == "-v" and ":/root/repos/datadog-agent" in docker_run_call[0][0][i+1]
1538+
]
1539+
assert len(repo_mounts) == 1
1540+
assert str(repo_dir) in repo_mounts[0]
1541+
1542+
def test_backward_compatibility_non_git_directory(self, dda, helpers, mocker, temp_dir, host_user_args):
1543+
"""Test backward compatibility: non-git directories are still found via parent directory."""
1544+
repos_dir = temp_dir / "repos"
1545+
repos_dir.ensure_dir()
1546+
repo_dir = repos_dir / "datadog-agent"
1547+
repo_dir.ensure_dir()
1548+
1549+
# Don't create .git directory - this is a non-git directory
1550+
# Mock git to fail (no remote available)
1551+
mocker.patch("dda.tools.git.Git.get_remote", side_effect=Exception("Not a git repository"))
1552+
1553+
write_server_config = mocker.patch("dda.utils.ssh.write_server_config")
1554+
with (
1555+
repo_dir.as_cwd(),
1556+
helpers.hybrid_patch(
1557+
"subprocess.run",
1558+
return_values={
1559+
# Start command checks the status
1560+
1: CompletedProcess([], returncode=0, stdout="{}"),
1561+
# Start method checks the status
1562+
2: CompletedProcess([], returncode=0, stdout="{}"),
1563+
# Capture image pull
1564+
# Capture container run
1565+
# Readiness check
1566+
5: CompletedProcess([], returncode=0, stdout="Server listening on :: port 22"),
1567+
},
1568+
) as calls,
1569+
):
1570+
result = dda("env", "dev", "start")
1571+
1572+
result.check(exit_code=0)
1573+
1574+
# Verify that the parent directory lookup worked
1575+
docker_run_call = calls[1]
1576+
repo_mounts = [
1577+
docker_run_call[0][0][i+1]
1578+
for i, arg in enumerate(docker_run_call[0][0])
1579+
if arg == "-v" and ":/root/repos/datadog-agent" in docker_run_call[0][0][i+1]
1580+
]
1581+
assert len(repo_mounts) == 1
1582+
assert str(repo_dir) in repo_mounts[0]
1583+
1584+
def test_repo_not_found_error(self, dda, helpers, mocker, temp_dir):
1585+
"""Test that appropriate error is shown when repository cannot be found."""
1586+
# Don't create any repository directory
1587+
mocker.patch("dda.tools.git.Git.get_remote", side_effect=Exception("Not a git repository"))
1588+
1589+
with (
1590+
temp_dir.as_cwd(),
1591+
helpers.hybrid_patch(
1592+
"subprocess.run",
1593+
return_values={
1594+
# Start command checks the status
1595+
1: CompletedProcess([], returncode=0, stdout="{}"),
1596+
# Start method checks the status
1597+
2: CompletedProcess([], returncode=0, stdout="{}"),
1598+
},
1599+
),
1600+
):
1601+
result = dda("env", "dev", "start")
1602+
1603+
result.check(
1604+
exit_code=1,
1605+
output=mocker.ANY, # Just check it errors, message may vary
1606+
)
1607+
assert "Local repository not found: datadog-agent" in result.output
1608+
1609+
14181610
class TestImportFiles:
14191611
"""Basic import operations."""
14201612

0 commit comments

Comments
 (0)