Skip to content
Merged
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
17 changes: 17 additions & 0 deletions docs/features/django-fork.rst
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,23 @@ working tree must be clean for a real sync (each branch is checked out via
``git switch``), and only branches present in the ``upstream_branch`` mapping
are synced.

To sync only a subset of the mapped branches instead of all of them, use
``--branch`` (``-B``). It behaves exactly like ``--all-branches`` — same
rebase/force-push/restore flow, same ``--dry-run`` and ``--no-ci`` support —
but restricts the run to the named branch(es). The flag is repeatable, and each
name must exist in the ``upstream_branch`` mapping:

.. code-block:: bash

# Sync just one release branch
dbx sync django -B mongodb-6.0.x

# Sync a few, in the given order
dbx sync django -B mongodb-6.0.x -B mongodb-6.1.x

# Preview a single branch without touching the tree
dbx sync django -B mongodb-6.0.x --dry-run

Combined with ``--dry-run``, ``--all-branches`` previews every mapped branch
*without* checking any of them out: it fetches upstream once and compares each
branch's ``origin`` ref against its configured upstream target directly. Because
Expand Down
55 changes: 45 additions & 10 deletions src/dbx_python_cli/commands/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,13 +88,19 @@ def _current_branch(repo_path):
return ""


def _sync_all_branches(repo_info, config, verbose, force, dry_run, no_ci=False):
"""Sync every branch in a repo's ``upstream_branch`` mapping.
def _sync_all_branches(
repo_info, config, verbose, force, dry_run, no_ci=False, branch_filter=None
):
"""Sync branches in a repo's ``upstream_branch`` mapping.

Checks out each mapped local branch in turn, runs the normal
fetch/rebase/push sequence, and restores the originally checked-out branch
when finished. Requires a dict-form ``upstream_branch`` mapping for the repo.

When ``branch_filter`` is a non-empty list of branch names, only those
branches are synced (each must exist in the mapping); otherwise every mapped
branch is synced.

Each branch is rebased onto its upstream target, which rewrites history, so a
plain push would always be rejected as non-fast-forward. Force-push (via the
safe ``--force-with-lease``) is therefore the default for this flow; ``--force``
Expand All @@ -121,7 +127,21 @@ def _sync_all_branches(repo_info, config, verbose, force, dry_run, no_ci=False):
)
raise typer.Exit(1)

branches = list(mapping.keys())
if branch_filter:
unknown = [b for b in branch_filter if b not in mapping]
if unknown:
typer.echo(
f"❌ Error: branch(es) not in upstream_branch mapping for '{name}': "
f"{', '.join(unknown)}",
err=True,
)
typer.echo(f"Available branches: {', '.join(mapping.keys())}", err=True)
raise typer.Exit(1)
# Preserve the requested order, de-duplicated.
seen = set()
branches = [b for b in branch_filter if not (b in seen or seen.add(b))]
else:
branches = list(mapping.keys())
original_branch = _current_branch(path)

typer.echo(
Expand Down Expand Up @@ -485,6 +505,12 @@ def sync_callback(
"-b",
help="Sync every branch in a repo's upstream_branch mapping (e.g. the Django fork)",
),
branch: list[str] = typer.Option(
None,
"--branch",
"-B",
help="Sync only the named branch(es) from the repo's upstream_branch mapping (repeatable)",
),
force: bool = typer.Option(
False,
"--force",
Expand Down Expand Up @@ -516,6 +542,7 @@ def sync_callback(

dbx sync <repo_name> # Sync a single repository
dbx sync <repo_name> --all-branches # Sync every branch in the repo's upstream_branch map
dbx sync <repo_name> -B <branch> # Sync only the named branch(es) from that map (repeatable)
dbx sync -g <group> # Sync all repos in a group
dbx sync -a # Sync all repos in all groups
dbx sync -g <group> <repo_name> # Sync specific repo in a group
Expand Down Expand Up @@ -599,13 +626,13 @@ def _enrich(repo_list):
)
return

# Handle syncing every branch in a single repo's upstream_branch mapping
if all_branches:
# Handle syncing specific/all branches in a single repo's
# upstream_branch mapping.
if all_branches or branch:
flag = "--all-branches" if all_branches else "--branch"
if not repo_name:
typer.echo(
"❌ Error: --all-branches requires a repository name", err=True
)
typer.echo("\nUsage: dbx sync <repo-name> --all-branches")
typer.echo(f"❌ Error: {flag} requires a repository name", err=True)
typer.echo(f"\nUsage: dbx sync <repo-name> {flag}")
raise typer.Exit(1)

if group:
Expand Down Expand Up @@ -638,7 +665,15 @@ def _enrich(repo_list):
typer.echo("\nUse 'dbx list' to see available repositories")
raise typer.Exit(1)

_sync_all_branches(repo_info, config, verbose, force, dry_run, no_ci)
_sync_all_branches(
repo_info,
config,
verbose,
force,
dry_run,
no_ci,
branch_filter=branch or None,
)
return

# Handle sync with both group and repo name specified
Expand Down
94 changes: 94 additions & 0 deletions tests/test_repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -1400,6 +1400,100 @@ def mock_run_side_effect(*args, **kwargs):
assert all("--force-with-lease" in cmd for cmd in push_calls)


def test_repo_sync_branch_filter(tmp_path, temp_repos_dir):
"""--branch syncs only the named mapped branch(es) and restores the original."""
config_path = tmp_path / ".config" / "dbx-python-cli" / "config.toml"
config_path.parent.mkdir(parents=True, exist_ok=True)
repos_dir_str = str(temp_repos_dir).replace("\\", "/")
config_content = f"""
[repo]
base_dir = "{repos_dir_str}"

[repo.groups.django]
repos = [
"git@github.com:mongodb-forks/django.git",
]

[repo.groups.django.upstream_branch]
django = {{"mongodb-6.0.x" = "stable/6.0.x", "mongodb-5.2.x" = "stable/5.2.x"}}
"""
config_path.write_text(config_content)

repo_dir = temp_repos_dir / "django" / "django"
repo_dir.mkdir(parents=True)
(repo_dir / ".git").mkdir()

state = {"current": "main"}

with patch("dbx_python_cli.utils.repo.get_config_path") as mock_get_path:
with patch("dbx_python_cli.commands.sync.subprocess.run") as mock_run:
mock_get_path.return_value = config_path

def mock_run_side_effect(*args, **kwargs):
cmd = args[0]
if "switch" in cmd:
state["current"] = cmd[-1]
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
elif "branch" in cmd and "--show-current" in cmd:
return subprocess.CompletedProcess(
cmd, 0, stdout=f"{state['current']}\n", stderr=""
)
elif "remote" in cmd and "add" not in cmd and "show" not in cmd:
return subprocess.CompletedProcess(
cmd, 0, stdout="origin\nupstream\n", stderr=""
)
else:
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")

mock_run.side_effect = mock_run_side_effect

result = runner.invoke(app, ["sync", "django", "-B", "mongodb-6.0.x"])
assert result.exit_code == 0

switch_targets = [
c[0][0][-1] for c in mock_run.call_args_list if "switch" in c[0][0]
]
# Only the requested branch was checked out; original restored last.
assert "mongodb-6.0.x" in switch_targets
assert "mongodb-5.2.x" not in switch_targets
assert switch_targets[-1] == "main"

rebase_targets = [
c[0][0][-1] for c in mock_run.call_args_list if "rebase" in c[0][0]
]
assert rebase_targets == ["upstream/stable/6.0.x"]


def test_repo_sync_branch_filter_unknown_branch(tmp_path, temp_repos_dir):
"""--branch with a name outside the upstream_branch mapping errors out."""
config_path = tmp_path / ".config" / "dbx-python-cli" / "config.toml"
config_path.parent.mkdir(parents=True, exist_ok=True)
repos_dir_str = str(temp_repos_dir).replace("\\", "/")
config_content = f"""
[repo]
base_dir = "{repos_dir_str}"

[repo.groups.django]
repos = [
"git@github.com:mongodb-forks/django.git",
]

[repo.groups.django.upstream_branch]
django = {{"mongodb-6.0.x" = "stable/6.0.x"}}
"""
config_path.write_text(config_content)

repo_dir = temp_repos_dir / "django" / "django"
repo_dir.mkdir(parents=True)
(repo_dir / ".git").mkdir()

with patch("dbx_python_cli.utils.repo.get_config_path") as mock_get_path:
mock_get_path.return_value = config_path
result = runner.invoke(app, ["sync", "django", "-B", "nope"])
assert result.exit_code == 1
assert "not in upstream_branch mapping" in result.output


def test_repo_sync_all_branches_aborts_failed_rebase(tmp_path, temp_repos_dir):
"""A failed rebase under --all-branches is aborted so remaining branches still run."""
config_path = tmp_path / ".config" / "dbx-python-cli" / "config.toml"
Expand Down
Loading