Skip to content

Commit e987f30

Browse files
aclark4lifeCopilot
andcommitted
Add dbx swap command to swap origin and upstream remotes
- New swap.py command supporting dbx swap <name>, dbx swap ., dbx swap -g <group> - --dry-run flag to preview changes without applying - Registered in cli.py between switch and sync - 7 tests in test_swap_command.py covering name, path, group, dry-run, and error cases - Docs added to repo-management.rst Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 157b856 commit e987f30

4 files changed

Lines changed: 443 additions & 1 deletion

File tree

docs/features/repo-management.rst

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
Repository Management
22
=====================
33

4-
The ``dbx clone``, ``dbx sync``, ``dbx branch``, ``dbx switch``, ``dbx log``, and ``dbx open`` commands provide repository management functionality for cloning and managing groups of related repositories.
4+
The ``dbx clone``, ``dbx sync``, ``dbx swap``, ``dbx branch``, ``dbx switch``, ``dbx log``, and ``dbx open`` commands provide repository management functionality for cloning and managing groups of related repositories.
55

66
Initialize Configuration
77
------------------------
@@ -202,6 +202,38 @@ This command will:
202202
- If there are rebase conflicts, you'll need to resolve them manually
203203
- Works with any repository that has an ``upstream`` remote, not just forks
204204

205+
Swap Origin and Upstream
206+
~~~~~~~~~~~~~~~~~~~~~~~~~
207+
208+
After setting up a fork workflow, you may occasionally need to swap your ``origin`` and ``upstream``
209+
remotes — for example, if you initially cloned the upstream repo directly and later created a fork.
210+
211+
.. code-block:: bash
212+
213+
# Swap remotes for a specific repository
214+
dbx swap mongo-python-driver
215+
216+
# Swap by navigating to the repo directory
217+
cd ~/Developer/mongodb/pymongo/mongo-python-driver
218+
dbx swap .
219+
220+
# Swap all repositories in a group
221+
dbx swap -g pymongo
222+
223+
# Preview what would change without making modifications
224+
dbx swap mongo-python-driver --dry-run
225+
dbx swap -g pymongo --dry-run
226+
227+
After swapping, what was ``origin`` becomes ``upstream`` and vice versa. This is useful when:
228+
229+
- You cloned a repo directly and then forked it — swap so your fork is ``origin``
230+
- You want to point ``origin`` back to the canonical repo and ``upstream`` to your fork
231+
232+
**Notes:**
233+
234+
- Both ``origin`` and ``upstream`` remotes must already be configured; the command will fail if either is missing
235+
- Use ``--dry-run`` first to verify the swap looks correct before applying it
236+
205237
**Available Groups (Default):**
206238

207239
- ``global`` - Reserved for repos shared across all groups (currently empty)

src/dbx_python_cli/cli.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
remove,
2323
spec,
2424
status,
25+
swap,
2526
switch,
2627
sync,
2728
test,
@@ -74,6 +75,7 @@ def get_help_text():
7475
app.add_typer(remove.app, name="remove")
7576
app.add_typer(spec.app, name="spec")
7677
app.add_typer(status.app, name="status")
78+
app.add_typer(swap.app, name="swap")
7779
app.add_typer(switch.app, name="switch")
7880
app.add_typer(sync.app, name="sync")
7981
app.add_typer(test.app, name="test")
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
"""Swap command for swapping origin and upstream remotes."""
2+
3+
import subprocess
4+
from pathlib import Path
5+
6+
import typer
7+
8+
from dbx_python_cli.utils.repo import (
9+
find_repo_by_name,
10+
find_repo_by_path,
11+
get_base_dir,
12+
get_config,
13+
)
14+
15+
app = typer.Typer(
16+
help="Swap origin and upstream git remotes",
17+
no_args_is_help=True,
18+
invoke_without_command=True,
19+
context_settings={
20+
"allow_interspersed_args": True,
21+
"help_option_names": ["-h", "--help"],
22+
},
23+
)
24+
25+
26+
def _get_remote_url(repo_path: Path, remote: str) -> str | None:
27+
"""Return the URL for a remote, or None if the remote doesn't exist."""
28+
try:
29+
result = subprocess.run(
30+
["git", "remote", "get-url", remote],
31+
cwd=repo_path,
32+
capture_output=True,
33+
text=True,
34+
check=True,
35+
)
36+
return result.stdout.strip()
37+
except subprocess.CalledProcessError:
38+
return None
39+
40+
41+
def _set_remote_url(repo_path: Path, remote: str, url: str) -> None:
42+
subprocess.run(
43+
["git", "remote", "set-url", remote, url],
44+
cwd=repo_path,
45+
check=True,
46+
)
47+
48+
49+
def _swap_remotes(
50+
repo_path: Path, repo_name: str, verbose: bool, dry_run: bool
51+
) -> bool:
52+
"""Swap origin and upstream for a single repo. Returns True on success."""
53+
origin_url = _get_remote_url(repo_path, "origin")
54+
upstream_url = _get_remote_url(repo_path, "upstream")
55+
56+
if not origin_url:
57+
typer.echo(f"❌ {repo_name}: no 'origin' remote found", err=True)
58+
return False
59+
if not upstream_url:
60+
typer.echo(f"❌ {repo_name}: no 'upstream' remote found", err=True)
61+
return False
62+
63+
if verbose or dry_run:
64+
typer.echo(f" origin: {origin_url}")
65+
typer.echo(f" upstream: {upstream_url}")
66+
67+
if dry_run:
68+
typer.echo(f" → would set origin={upstream_url}, upstream={origin_url}")
69+
return True
70+
71+
_set_remote_url(repo_path, "origin", upstream_url)
72+
_set_remote_url(repo_path, "upstream", origin_url)
73+
typer.echo(
74+
f"🔄 {repo_name}: swapped — origin={upstream_url}, upstream={origin_url}"
75+
)
76+
return True
77+
78+
79+
@app.callback()
80+
def swap_callback(
81+
ctx: typer.Context,
82+
repo_name: str = typer.Argument(
83+
None,
84+
help="Repository name or '.' for the current directory",
85+
),
86+
group: str = typer.Option(
87+
None,
88+
"--group",
89+
"-g",
90+
help="Swap remotes in all repositories in a group",
91+
),
92+
dry_run: bool = typer.Option(
93+
False,
94+
"--dry-run",
95+
help="Show what would be swapped without making changes",
96+
),
97+
):
98+
"""Swap origin and upstream git remotes in a repository.
99+
100+
Usage::
101+
102+
dbx swap <repo_name> # Swap by name
103+
dbx swap . # Swap in the current directory
104+
dbx swap -g <group> # Swap all repos in a group
105+
106+
Examples::
107+
108+
dbx swap mongo-python-driver
109+
dbx swap .
110+
dbx swap -g pymongo --dry-run
111+
"""
112+
verbose = ctx.obj.get("verbose", False) if ctx.obj else False
113+
114+
try:
115+
config = get_config()
116+
base_dir = get_base_dir(config)
117+
except Exception as e:
118+
typer.echo(f"❌ Error: {e}", err=True)
119+
raise typer.Exit(1)
120+
121+
# Group mode
122+
if group:
123+
from dbx_python_cli.utils.repo import find_all_repos
124+
125+
all_repos = find_all_repos(base_dir, config)
126+
group_repos = [r for r in all_repos if r["group"] == group]
127+
if not group_repos:
128+
typer.echo(
129+
f"❌ Error: No cloned repositories found in group '{group}'", err=True
130+
)
131+
raise typer.Exit(1)
132+
typer.echo(
133+
f"{'[dry-run] ' if dry_run else ''}Swapping remotes in {len(group_repos)} repo(s) in group '{group}':\n"
134+
)
135+
for r in sorted(group_repos, key=lambda x: x["name"]):
136+
if verbose or dry_run:
137+
typer.echo(f"📦 {r['name']}:")
138+
_swap_remotes(r["path"], r["name"], verbose, dry_run)
139+
return
140+
141+
# Single repo mode
142+
if not repo_name:
143+
typer.echo("❌ Error: Repository name or -g <group> required", err=True)
144+
raise typer.Exit(1)
145+
146+
_is_path_like = (
147+
repo_name in (".", "..")
148+
or repo_name.startswith(("./", "../", "/", "~/"))
149+
or "/" in repo_name
150+
or Path(repo_name).is_dir()
151+
)
152+
153+
if _is_path_like:
154+
repo_info = find_repo_by_path(repo_name, base_dir, config)
155+
if not repo_info:
156+
typer.echo(
157+
f"❌ Error: No managed repository found at '{Path(repo_name).resolve()}'",
158+
err=True,
159+
)
160+
raise typer.Exit(1)
161+
else:
162+
repo_info = find_repo_by_name(repo_name, base_dir, config)
163+
if not repo_info:
164+
typer.echo(f"❌ Error: Repository '{repo_name}' not found", err=True)
165+
typer.echo("\nUse 'dbx list' to see available repositories")
166+
raise typer.Exit(1)
167+
168+
if dry_run or verbose:
169+
typer.echo(f"📦 {repo_info['name']}:")
170+
if not _swap_remotes(repo_info["path"], repo_info["name"], verbose, dry_run):
171+
raise typer.Exit(1)

0 commit comments

Comments
 (0)