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
8 changes: 8 additions & 0 deletions docs/api/commands.rst
Original file line number Diff line number Diff line change
Expand Up @@ -130,3 +130,11 @@ dbx_python_cli.commands.test module
:members:
:undoc-members:
:show-inheritance:

dbx_python_cli.commands.worktree module
---------------------------------------

.. automodule:: dbx_python_cli.commands.worktree
:members:
:undoc-members:
:show-inheritance:
8 changes: 8 additions & 0 deletions docs/api/utils.rst
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,11 @@ dbx_python_cli.utils.venv module
:members:
:undoc-members:
:show-inheritance:

dbx_python_cli.utils.worktree module
------------------------------------

.. automodule:: dbx_python_cli.utils.worktree
:members:
:undoc-members:
:show-inheritance:
5 changes: 5 additions & 0 deletions docs/design/command-structure.rst
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,11 @@ Related subcommands that share a common domain:
dbx project list
dbx project remove

# Git worktree management
dbx worktree add django --upstream
dbx worktree list django
dbx worktree remove django

Command Groups (Two Levels)
~~~~~~~~~~~~~~~~~~~~~~~~~~~

Expand Down
89 changes: 89 additions & 0 deletions docs/features/django-fork.rst
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ organisation rather than a personal account:
[repo.groups.django.preferred_branch]
django = "mongodb-6.0.x"

# Add a `django-upstream` worktree on clone so the fork and upstream Django
# can both be worked on from one clone
upstream_worktree = ["django"]

Each key plays a specific role in fork maintenance:

``no_fork``
Expand All @@ -65,6 +69,11 @@ Each key plays a specific role in fork maintenance:
``preferred_branch``
The branch ``dbx clone`` switches to automatically after cloning.

``upstream_worktree``
Lists repos that get a sibling git worktree checked out on the upstream
default branch immediately after cloning. See
:ref:`django-fork-upstream-worktree`.

Cloning the fork
----------------

Expand All @@ -87,6 +96,86 @@ To clone only the Django fork itself:

dbx clone django

.. _django-fork-upstream-worktree:

Developing against the fork and upstream from one clone
-------------------------------------------------------

Fork maintenance regularly needs both sides at once: reading upstream Django to
see what changed, and editing the fork branch to adapt to it. A second clone
would mean a second full fetch of Django's history and two object stores to keep
current.

A git *worktree* avoids that. One clone can have several branches checked out at
the same time, each in its own directory, all sharing a single ``.git`` object
store. Because ``upstream_worktree = ["django"]`` is set, ``dbx clone`` creates
one automatically:

.. code-block:: text

~/Developer/mongodb/django/
django/ # primary clone, on mongodb-6.0.x (origin = mongodb-forks)
django-upstream/ # worktree, on upstream-main (tracks upstream/main)

The worktree's branch is named ``upstream-<default branch>`` rather than just
``main``, so it cannot collide with a branch of the same name already tracking
``origin`` in the fork.

Fetching in either directory updates the shared object store, so comparisons
between the two are local and immediate:

.. code-block:: bash

cd ~/Developer/mongodb/django/django
git log upstream-main..mongodb-6.0.x # what the fork adds
git diff upstream-main -- django/db/models # what the fork changed

Managing worktrees manually
~~~~~~~~~~~~~~~~~~~~~~~~~~~

``dbx worktree`` manages worktrees for any repo, whether or not
``upstream_worktree`` is configured:

.. code-block:: bash

# Add the upstream worktree (django-upstream, on the upstream default branch)
dbx worktree add django --upstream

# Check out a specific upstream branch (django-stable-6.1.x)
dbx worktree add django stable/6.1.x --upstream

# Check out an existing fork branch alongside the current one
dbx worktree add django mongodb-6.2.x

# Override the directory suffix
dbx worktree add django stable/6.1.x --upstream --label 61

# Show every checkout attached to the clone (* marks the primary one)
dbx worktree list django

# Remove by directory suffix (defaults to `upstream`)
dbx worktree remove django
dbx worktree remove django 61 --force

Removal goes through ``git worktree remove`` so the registration inside the
primary clone is cleaned up too; deleting the directory by hand would leave a
stale entry that blocks recreating the worktree later. ``dbx remove`` handles
this as well, removing worktrees before the clone they belong to.

A branch can only be checked out in one worktree at a time. ``dbx worktree add``
reports git's refusal rather than working around it, so the fork branch you are
editing is never silently moved out from under you.

.. note::

``django-upstream`` is skipped by ``dbx sync``, ``dbx switch -g django`` and
``dbx install -g django`` — see :ref:`upstream-worktrees` for why. It still
appears in ``dbx status``, ``dbx log`` and ``dbx branch``, which only read.

In particular, the fork clone stays the installed one: a Django worktree has
the same package name, so installing it would replace the fork checkout as
the live ``django`` package in the group venv.

Syncing a branch with upstream
------------------------------

Expand Down
74 changes: 74 additions & 0 deletions docs/features/repo-management.rst
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,79 @@ completes — equivalent to running ``dbx sync mongo-python-driver`` right after
- Like ``dbx sync``, it's a no-op (with a warning) if no ``upstream`` remote ends up configured for the repo
- Pass ``--no-sync`` to ``dbx clone`` to skip this for a single invocation even when configured

.. _upstream-worktrees:

Upstream Worktrees
~~~~~~~~~~~~~~~~~~

A git worktree lets a single clone keep several branches checked out at once, in
separate directories sharing one object store. That makes it possible to develop
against a fork *and* its upstream without cloning the upstream separately.

List a repo in ``upstream_worktree`` to have ``dbx clone`` create one:

.. code-block:: toml

[repo.groups.django]
upstream_worktree = ["django"]

Cloning then produces a sibling directory checked out on the upstream default
branch, under a branch named ``upstream-<default branch>`` so it cannot collide
with a same-named branch tracking ``origin``:

.. code-block:: text

base_dir/django/django/ # fork clone, on its preferred branch
base_dir/django/django-upstream/ # worktree, on upstream-main

Manage worktrees for any repo with ``dbx worktree``:

.. code-block:: bash

dbx worktree add <repo> --upstream # upstream default branch
dbx worktree add <repo> <branch> # an existing branch, alongside the current one
dbx worktree add <repo> <branch> -u # an upstream branch
dbx worktree list <repo> # every checkout (* marks the primary clone)
dbx worktree remove <repo> [<label>] # defaults to the `upstream` worktree

**Notes:**

- Requires an ``upstream`` remote; on clone this comes from the ``upstream`` config key
- Worktree directories are named ``<repo>-<label>``, where ``label`` defaults to the branch name with ``/`` replaced by ``-``
- A branch can only be checked out in one worktree at a time; ``dbx worktree add`` reports git's refusal rather than moving it
- ``dbx remove`` unregisters worktrees via ``git worktree remove`` and removes them before the clone they belong to

Which commands see worktrees
^^^^^^^^^^^^^^^^^^^^^^^^^^^^

Commands that only read are happy to include worktrees; commands that write to
a checkout skip them:

.. list-table::
:header-rows: 1
:widths: 25 15 60

* - Command
- Worktrees
- Why
* - ``dbx status``, ``dbx log``, ``dbx branch``
- included
- Read-only, and seeing the upstream checkout's state is useful
* - ``dbx sync``
- skipped
- Rebasing a worktree onto upstream and force-pushing it to ``origin`` would push upstream's history into the fork
* - ``dbx switch -g <group>``
- skipped
- A branch can only be checked out in one worktree at a time, so a bulk switch would fail or move a checkout in use
* - ``dbx install -g <group>``
- skipped
- A worktree shares its clone's package name; installing it would replace the clone's editable install in the group venv

``dbx sync`` refuses a worktree even when you name one explicitly, because the
force-push is not recoverable. ``dbx switch <worktree> <branch>`` is allowed —
it is reversible, and git refuses on its own if the branch is checked out
elsewhere.

.. _config-driven-upstream:

Config-Driven Upstream Remotes
Expand Down Expand Up @@ -428,6 +501,7 @@ Per-group keys of note:
- ``upstream`` — upstream remote URLs added automatically on clone (see :ref:`config-driven-upstream`)
- ``upstream_branch`` — upstream branch override for ``dbx sync`` (see :ref:`config-driven-upstream`)
- ``sync_after_clone`` — list of repo names to automatically ``dbx sync`` immediately after cloning (see :ref:`sync-after-clone`)
- ``upstream_worktree`` — list of repo names that get an upstream git worktree created on clone (see :ref:`upstream-worktrees`)
- ``install_extras``, ``install_groups`` — default extras / dependency groups installed by ``dbx install``
- ``install_dirs`` — sub-directory paths for repos that contain multiple packages
- ``build_commands`` — shell commands run before installation (e.g. a Rust or CMake build)
Expand Down
2 changes: 2 additions & 0 deletions src/dbx_python_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
switch,
sync,
test,
worktree,
)


Expand Down Expand Up @@ -77,6 +78,7 @@ def get_help_text():
app.add_typer(switch.app, name="switch")
app.add_typer(sync.app, name="sync")
app.add_typer(test.app, name="test")
app.add_typer(worktree.app, name="worktree")


def version_callback(value: bool):
Expand Down
21 changes: 21 additions & 0 deletions src/dbx_python_cli/commands/clone.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import typer

from dbx_python_cli.commands.worktree import create_upstream_worktree
from dbx_python_cli.utils import repo
from dbx_python_cli.utils.repo import (
get_group_dir,
Expand All @@ -13,6 +14,8 @@
get_upstream_url,
is_flat_mode,
should_sync_after_clone,
)
from dbx_python_cli.utils.repo import (
switch_to_branch as _switch_to_branch,
)

Expand Down Expand Up @@ -770,6 +773,24 @@ def clone_callback(
repo_path, repo_name, config_group, config, verbose
)

# Create the upstream worktree if configured, so the fork
# and its upstream can both be worked on from this clone.
if clone_success and repo.should_create_upstream_worktree(
config, config_group, repo_name
):
wt_ok, wt_message = create_upstream_worktree(
repo_path, repo_name, config_group, config, verbose
)
if wt_ok:
typer.echo(
f" 🌿 {repo_name}: upstream worktree at {wt_message}"
)
else:
typer.echo(
f" ⚠️ {repo_name}: upstream worktree skipped ({wt_message})",
err=True,
)

# Track successful clone for auto-install
if clone_success:
cloned_repos.append(
Expand Down
24 changes: 20 additions & 4 deletions src/dbx_python_cli/commands/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -494,7 +494,11 @@ def install_callback(
)
raise typer.Exit(1)

group_repos = [r for r in all_repos if r["group"] == grp]
# Worktrees share their clone's package name; installing one would
# replace the primary checkout's editable install in the group venv.
group_repos = [
r for r in all_repos if r["group"] == grp and not r.get("worktree")
]
if not group_repos:
typer.echo(
f"❌ Error: No repositories found in group '{grp}'", err=True
Expand All @@ -509,7 +513,11 @@ def install_callback(

# Show options for all groups
for grp in groups:
group_repos = [r for r in all_repos if r["group"] == grp]
# Worktrees share their clone's package name; installing one would
# replace the primary checkout's editable install in the group venv.
group_repos = [
r for r in all_repos if r["group"] == grp and not r.get("worktree")
]

if len(groups) > 1:
typer.echo(f"{'#' * 60}")
Expand Down Expand Up @@ -676,7 +684,11 @@ def install_callback(
typer.echo(f"❌ Error: Group '{grp}' not found in {base_dir}", err=True)
raise typer.Exit(1)

group_repos = [r for r in all_repos if r["group"] == grp]
# Worktrees share their clone's package name; installing one would
# replace the primary checkout's editable install in the group venv.
group_repos = [
r for r in all_repos if r["group"] == grp and not r.get("worktree")
]
if not group_repos:
typer.echo(
f"❌ Error: No repositories found in group '{grp}'", err=True
Expand All @@ -696,7 +708,11 @@ def install_callback(

for grp in groups:
group_path = get_group_dir(base_dir, grp, flat)
group_repos = [r for r in all_repos if r["group"] == grp]
# Worktrees share their clone's package name; installing one would
# replace the primary checkout's editable install in the group venv.
group_repos = [
r for r in all_repos if r["group"] == grp and not r.get("worktree")
]

if len(groups) > 1:
typer.echo(f"\n{'#' * 60}")
Expand Down
28 changes: 24 additions & 4 deletions src/dbx_python_cli/commands/remove.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import typer

from dbx_python_cli.utils import repo
from dbx_python_cli.utils.worktree import prune_worktrees, remove_worktree

app = typer.Typer(
help="Remove repositories or repository groups",
Expand Down Expand Up @@ -180,13 +181,32 @@ def remove_callback(
failed_count = 0

typer.echo()
# Remove worktrees before their primary clones: `git worktree remove` needs
# the clone's .git directory, and deleting the clone first would strand the
# worktree with a dangling gitdir pointer.
repos_to_remove = sorted(
repos_to_remove, key=lambda r: not r.get("worktree", False)
)
for repo_info in repos_to_remove:
repo_path = Path(repo_info["path"])
try:
if verbose:
typer.echo(f"[verbose] Removing directory: {repo_path}")

shutil.rmtree(repo_path)
if repo_info.get("worktree"):
if verbose:
typer.echo(f"[verbose] Removing worktree: {repo_path}")
# --force: the confirmation prompt above is the safety gate, and
# git otherwise refuses whenever the worktree is dirty.
ok, message = remove_worktree(
repo_path, repo_path, force=True, verbose=verbose
)
if not ok:
raise RuntimeError(message)
else:
if verbose:
typer.echo(f"[verbose] Removing directory: {repo_path}")
# Drop registrations for worktrees removed above so the clone's
# metadata is consistent if anything later inspects it.
prune_worktrees(repo_path, verbose=verbose)
shutil.rmtree(repo_path)
typer.echo(f"✅ Removed {repo_info['name']} ({repo_info['group']})")
removed_count += 1
except Exception as e:
Expand Down
Loading
Loading