diff --git a/docs/features/django-fork.rst b/docs/features/django-fork.rst index b24f3fd..29ba5a3 100644 --- a/docs/features/django-fork.rst +++ b/docs/features/django-fork.rst @@ -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 diff --git a/src/dbx_python_cli/commands/sync.py b/src/dbx_python_cli/commands/sync.py index 6a3d01c..c1452c2 100644 --- a/src/dbx_python_cli/commands/sync.py +++ b/src/dbx_python_cli/commands/sync.py @@ -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`` @@ -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( @@ -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", @@ -516,6 +542,7 @@ def sync_callback( dbx sync # Sync a single repository dbx sync --all-branches # Sync every branch in the repo's upstream_branch map + dbx sync -B # Sync only the named branch(es) from that map (repeatable) dbx sync -g # Sync all repos in a group dbx sync -a # Sync all repos in all groups dbx sync -g # Sync specific repo in a group @@ -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 --all-branches") + typer.echo(f"❌ Error: {flag} requires a repository name", err=True) + typer.echo(f"\nUsage: dbx sync {flag}") raise typer.Exit(1) if group: @@ -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 diff --git a/tests/test_repo.py b/tests/test_repo.py index a14758c..0ee14c1 100644 --- a/tests/test_repo.py +++ b/tests/test_repo.py @@ -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"