Skip to content

Commit 2bef523

Browse files
committed
feat: allow dbx sync to accept '.' or any path as repo argument
- Add find_repo_by_path() to utils/repo.py that resolves a filesystem path to a managed repo, including when called from a subdirectory - Update sync_callback to detect path-like inputs (., .., absolute/ relative paths) and route them through find_repo_by_path instead of find_repo_by_name - Add tests for syncing from repo root, from a subdirectory, and from an unmanaged directory
1 parent 8163c14 commit 2bef523

3 files changed

Lines changed: 191 additions & 6 deletions

File tree

src/dbx_python_cli/commands/sync.py

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ def sync_callback(
8686
dbx sync -a --dry-run # Preview all groups sync
8787
dbx sync -g pymongo mongo-python-driver --dry-run # Preview specific repo
8888
"""
89-
from dbx_python_cli.utils.repo import find_all_repos, find_repo_by_name
89+
from dbx_python_cli.utils.repo import find_all_repos, find_repo_by_name, find_repo_by_path
9090

9191
# Get verbose flag from parent context
9292
verbose = ctx.obj.get("verbose", False) if ctx.obj else False
@@ -269,12 +269,30 @@ def sync_callback(
269269
typer.echo(" or: dbx sync -g <group> <repo-name>")
270270
raise typer.Exit(1)
271271

272+
# Detect path-like inputs: ".", "..", absolute paths, relative paths with /
273+
_is_path_like = (
274+
repo_name in (".", "..")
275+
or repo_name.startswith(("./", "../", "/", "~/"))
276+
or "/" in repo_name
277+
or Path(repo_name).is_dir()
278+
)
279+
272280
# Find the repository
273-
repo_info = find_repo_by_name(repo_name, base_dir, config)
274-
if not repo_info:
275-
typer.echo(f"❌ Error: Repository '{repo_name}' not found", err=True)
276-
typer.echo("\nUse 'dbx list' to see available repositories")
277-
raise typer.Exit(1)
281+
if _is_path_like:
282+
repo_info = find_repo_by_path(repo_name, base_dir, config)
283+
if not repo_info:
284+
typer.echo(
285+
f"❌ Error: No managed repository found at '{Path(repo_name).resolve()}'",
286+
err=True,
287+
)
288+
typer.echo("\nUse 'dbx list' to see available repositories")
289+
raise typer.Exit(1)
290+
else:
291+
repo_info = find_repo_by_name(repo_name, base_dir, config)
292+
if not repo_info:
293+
typer.echo(f"❌ Error: Repository '{repo_name}' not found", err=True)
294+
typer.echo("\nUse 'dbx list' to see available repositories")
295+
raise typer.Exit(1)
278296

279297
_sync_repository(repo_info["path"], repo_info["name"], verbose, force, dry_run)
280298

src/dbx_python_cli/utils/repo.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -629,6 +629,46 @@ def find_repo_by_name(repo_name, base_dir, config=None):
629629
return matching_repos[0]
630630

631631

632+
def find_repo_by_path(path, base_dir, config=None):
633+
"""
634+
Find a repository by filesystem path.
635+
636+
Resolves *path* to an absolute path and checks all known repos for a
637+
match. An exact match on the repo root is tried first; if not found,
638+
the function checks whether *path* is located *inside* a known repo
639+
(useful when the caller is in a subdirectory of the repo).
640+
641+
Args:
642+
path: A :class:`pathlib.Path` (or anything accepted by ``Path()``)
643+
pointing at or inside the repository root.
644+
base_dir: Path to the base directory containing group subdirectories.
645+
config: Optional configuration dictionary.
646+
647+
Returns:
648+
dict: Dictionary with ``'name'``, ``'path'``, and ``'group'`` keys,
649+
or ``None`` if no matching repository is found.
650+
"""
651+
from pathlib import Path as _Path
652+
653+
resolved = _Path(path).resolve()
654+
all_repos = find_all_repos(base_dir, config)
655+
656+
# Exact match first
657+
for r in all_repos:
658+
if r["path"].resolve() == resolved:
659+
return r
660+
661+
# Path is inside a repo (e.g. a subdirectory)
662+
for r in all_repos:
663+
try:
664+
resolved.relative_to(r["path"].resolve())
665+
return r
666+
except ValueError:
667+
continue
668+
669+
return None
670+
671+
632672
def find_all_repos_by_name(repo_name, base_dir, config=None):
633673
"""
634674
Find all repositories with a given name in the base directory.

tests/test_repo.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -900,6 +900,133 @@ def mock_run_side_effect(*args, **kwargs):
900900
assert "main" in push_calls[0][0][0]
901901

902902

903+
def test_repo_sync_dot_from_repo_root(tmp_path, temp_repos_dir, monkeypatch):
904+
"""Test syncing with '.' resolves to the repo at the current directory."""
905+
config_path = tmp_path / ".config" / "dbx-python-cli" / "config.toml"
906+
config_path.parent.mkdir(parents=True, exist_ok=True)
907+
repos_dir_str = str(temp_repos_dir).replace("\\", "/")
908+
config_content = f"""
909+
[repo]
910+
base_dir = "{repos_dir_str}"
911+
912+
[repo.groups.test]
913+
repos = [
914+
"git@github.com:mongodb/mongo-python-driver.git",
915+
]
916+
"""
917+
config_path.write_text(config_content)
918+
919+
# Create mock repository
920+
group_dir = temp_repos_dir / "test"
921+
repo_dir = group_dir / "mongo-python-driver"
922+
repo_dir.mkdir(parents=True)
923+
(repo_dir / ".git").mkdir()
924+
925+
# Change into the repo root so that "." resolves to it
926+
monkeypatch.chdir(repo_dir)
927+
928+
with patch("dbx_python_cli.utils.repo.get_config_path") as mock_get_path:
929+
with patch("dbx_python_cli.commands.sync.subprocess.run") as mock_run:
930+
mock_get_path.return_value = config_path
931+
932+
def mock_run_side_effect(*args, **kwargs):
933+
cmd = args[0]
934+
if "remote" in cmd and "add" not in cmd:
935+
return subprocess.CompletedProcess(
936+
cmd, 0, stdout="origin\nupstream\n", stderr=""
937+
)
938+
elif "branch" in cmd and "--show-current" in cmd:
939+
return subprocess.CompletedProcess(cmd, 0, stdout="main\n", stderr="")
940+
else:
941+
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
942+
943+
mock_run.side_effect = mock_run_side_effect
944+
945+
result = runner.invoke(app, ["sync", "."])
946+
assert result.exit_code == 0
947+
assert "Syncing mongo-python-driver" in result.stdout
948+
assert "Synced and pushed successfully" in result.stdout
949+
950+
951+
def test_repo_sync_dot_from_repo_subdirectory(tmp_path, temp_repos_dir, monkeypatch):
952+
"""Test that '.' resolves correctly when run from inside a repo subdirectory."""
953+
config_path = tmp_path / ".config" / "dbx-python-cli" / "config.toml"
954+
config_path.parent.mkdir(parents=True, exist_ok=True)
955+
repos_dir_str = str(temp_repos_dir).replace("\\", "/")
956+
config_content = f"""
957+
[repo]
958+
base_dir = "{repos_dir_str}"
959+
960+
[repo.groups.test]
961+
repos = [
962+
"git@github.com:mongodb/mongo-python-driver.git",
963+
]
964+
"""
965+
config_path.write_text(config_content)
966+
967+
# Create mock repository with a subdirectory
968+
group_dir = temp_repos_dir / "test"
969+
repo_dir = group_dir / "mongo-python-driver"
970+
subdir = repo_dir / "src" / "pymongo"
971+
subdir.mkdir(parents=True)
972+
(repo_dir / ".git").mkdir()
973+
974+
# Change into a subdirectory of the repo
975+
monkeypatch.chdir(subdir)
976+
977+
with patch("dbx_python_cli.utils.repo.get_config_path") as mock_get_path:
978+
with patch("dbx_python_cli.commands.sync.subprocess.run") as mock_run:
979+
mock_get_path.return_value = config_path
980+
981+
def mock_run_side_effect(*args, **kwargs):
982+
cmd = args[0]
983+
if "remote" in cmd and "add" not in cmd:
984+
return subprocess.CompletedProcess(
985+
cmd, 0, stdout="origin\nupstream\n", stderr=""
986+
)
987+
elif "branch" in cmd and "--show-current" in cmd:
988+
return subprocess.CompletedProcess(cmd, 0, stdout="main\n", stderr="")
989+
else:
990+
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
991+
992+
mock_run.side_effect = mock_run_side_effect
993+
994+
result = runner.invoke(app, ["sync", "."])
995+
assert result.exit_code == 0
996+
assert "Syncing mongo-python-driver" in result.stdout
997+
assert "Synced and pushed successfully" in result.stdout
998+
999+
1000+
def test_repo_sync_dot_not_in_managed_repo(tmp_path, temp_repos_dir, monkeypatch):
1001+
"""Test that '.' in an unmanaged directory gives a clear error."""
1002+
config_path = tmp_path / ".config" / "dbx-python-cli" / "config.toml"
1003+
config_path.parent.mkdir(parents=True, exist_ok=True)
1004+
repos_dir_str = str(temp_repos_dir).replace("\\", "/")
1005+
config_content = f"""
1006+
[repo]
1007+
base_dir = "{repos_dir_str}"
1008+
1009+
[repo.groups.test]
1010+
repos = [
1011+
"git@github.com:mongodb/mongo-python-driver.git",
1012+
]
1013+
"""
1014+
config_path.write_text(config_content)
1015+
1016+
# Change into a directory that is NOT a managed repo
1017+
unrelated_dir = tmp_path / "unrelated"
1018+
unrelated_dir.mkdir()
1019+
monkeypatch.chdir(unrelated_dir)
1020+
1021+
with patch("dbx_python_cli.utils.repo.get_config_path") as mock_get_path:
1022+
mock_get_path.return_value = config_path
1023+
1024+
result = runner.invoke(app, ["sync", "."])
1025+
assert result.exit_code == 1
1026+
output = result.stdout + result.stderr
1027+
assert "No managed repository found" in output
1028+
1029+
9031030
def test_repo_sync_group(tmp_path, temp_repos_dir):
9041031
"""Test syncing all repositories in a group."""
9051032
config_path = tmp_path / ".config" / "dbx-python-cli" / "config.toml"

0 commit comments

Comments
 (0)