Skip to content
Open
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
53 changes: 49 additions & 4 deletions dulwich/repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,31 @@ def parse_shared_repository(
return (None, None)


def _enable_relative_worktrees_extension(repo: "Repo") -> None:
"""Enable the relativeworktrees extension in repository config.

This sets core.repositoryformatversion to 1 (if not already) and
enables the extensions.relativeworktrees extension.

Args:
repo: The repository to configure
"""
config = repo.get_config()

# Ensure repository format version is at least 1
try:
version = int(config.get(("core",), "repositoryformatversion"))
except KeyError:
version = 0

if version < 1:
config.set(("core",), "repositoryformatversion", "1")

# Enable the relativeworktrees extension
config.set(("extensions",), "relativeworktrees", True)
config.write_to_path()


class ParentsProvider:
"""Provider for commit parent information."""

Expand Down Expand Up @@ -2200,6 +2225,7 @@ def _init_new_working_directory(
main_repo: "Repo",
identifier: str | None = None,
mkdir: bool = False,
relative_paths: bool = False,
) -> "Repo":
"""Create a new working directory linked to a repository.

Expand All @@ -2208,6 +2234,7 @@ def _init_new_working_directory(
main_repo: Main repository to reference
identifier: Worktree identifier
mkdir: Whether to create the directory
relative_paths: Whether to use relative paths for gitdir references
Returns: `Repo` instance
"""
path = os.fspath(path)
Expand All @@ -2221,9 +2248,21 @@ def _init_new_working_directory(
main_controldir = os.path.abspath(main_repo.controldir())
main_worktreesdir = os.path.join(main_controldir, WORKTREES)
worktree_controldir = os.path.join(main_worktreesdir, identifier)
gitdirfile = os.path.join(path, CONTROLDIR)
with open(gitdirfile, "wb") as f:
f.write(b"gitdir: " + os.fsencode(worktree_controldir) + b"\n")
gitdirfile_abs = os.path.abspath(os.path.join(path, CONTROLDIR))

# Write gitdir reference in .git file (can be relative)
# Import helper from worktree module to avoid duplication
from .worktree import _compute_gitdir_path

gitdir_ref = _compute_gitdir_path(
main_repo,
worktree_controldir,
os.path.dirname(gitdirfile_abs),
relative_paths,
)

with open(gitdirfile_abs, "wb") as f:
f.write(b"gitdir: " + os.fsencode(gitdir_ref) + b"\n")

# Get shared repository permissions from main repository
_, dir_mode = main_repo._get_shared_repository_permissions()
Expand All @@ -2241,8 +2280,14 @@ def _init_new_working_directory(
os.chmod(worktree_controldir, dir_mode)
except FileExistsError:
pass

# Write gitdir path in control directory (can be relative)
gitdir_path = _compute_gitdir_path(
main_repo, gitdirfile_abs, worktree_controldir, relative_paths
)

with open(os.path.join(worktree_controldir, GITDIR), "wb") as f:
f.write(os.fsencode(gitdirfile) + b"\n")
f.write(os.fsencode(gitdir_path) + b"\n")
with open(os.path.join(worktree_controldir, COMMONDIR), "wb") as f:
f.write(b"../..\n")
with open(os.path.join(worktree_controldir, "HEAD"), "wb") as f:
Expand Down
153 changes: 144 additions & 9 deletions dulwich/worktree.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,68 @@
from .trailers import add_trailer_to_message


def _should_use_relative_paths(
repo: Repo,
relative_paths: bool | None,
existing_path: bytes | None = None,
) -> bool:
"""Determine whether to use relative paths for gitdir references.

Args:
repo: The repository
relative_paths: Explicit preference (True/False) or None to check config
existing_path: Optional existing path to check format (for preserving format)

Returns:
True if relative paths should be used, False otherwise
"""
if relative_paths is not None:
return relative_paths

# Check config
config = repo.get_config()
try:
use_relative = config.get_boolean(
(b"worktree",), b"useRelativePaths", default=False
)
if use_relative:
return True
except KeyError:
pass

# Preserve existing format if available
if existing_path is not None:
return not os.path.isabs(os.fsdecode(existing_path))

return False


def _compute_gitdir_path(
repo: Repo,
gitdir_file: str,
worktree_control_dir: str,
use_relative: bool,
) -> str:
"""Compute the gitdir path and enable extension if needed.

Args:
repo: The repository
gitdir_file: Absolute path to the .git file
worktree_control_dir: Absolute path to the worktree control directory
use_relative: Whether to use relative paths

Returns:
The path to write (relative or absolute)
"""
if use_relative:
from .repo import _enable_relative_worktrees_extension

_enable_relative_worktrees_extension(repo)
return os.path.relpath(gitdir_file, worktree_control_dir)
else:
return gitdir_file


class WorkTreeInfo:
"""Information about a single worktree.

Expand Down Expand Up @@ -176,6 +238,7 @@ def add(
force: bool = False,
detach: bool = False,
exist_ok: bool = False,
relative_paths: bool | None = None,
) -> Repo:
"""Add a new worktree.

Expand All @@ -186,6 +249,8 @@ def add(
force: Force creation even if branch is already checked out elsewhere
detach: Detach HEAD in the new worktree
exist_ok: If True, do not raise an error if the directory already exists
relative_paths: If True, use relative paths for gitdir references.
If None, check worktree.useRelativePaths config (defaults to False)

Returns:
The newly created worktree repository
Expand All @@ -198,6 +263,7 @@ def add(
force=force,
detach=detach,
exist_ok=exist_ok,
relative_paths=relative_paths,
)

def remove(self, path: str | bytes | os.PathLike[str], force: bool = False) -> None:
Expand Down Expand Up @@ -227,14 +293,17 @@ def move(
self,
old_path: str | bytes | os.PathLike[str],
new_path: str | bytes | os.PathLike[str],
relative_paths: bool | None = None,
) -> None:
"""Move a worktree to a new location.

Args:
old_path: Current path of the worktree
new_path: New path for the worktree
relative_paths: If True, use relative paths for gitdir references.
If None, check worktree.useRelativePaths config or preserve existing format
"""
move_worktree(self._repo, old_path, new_path)
move_worktree(self._repo, old_path, new_path, relative_paths=relative_paths)

def lock(
self, path: str | bytes | os.PathLike[str], reason: str | None = None
Expand All @@ -256,18 +325,22 @@ def unlock(self, path: str | bytes | os.PathLike[str]) -> None:
unlock_worktree(self._repo, path)

def repair(
self, paths: Sequence[str | bytes | os.PathLike[str]] | None = None
self,
paths: Sequence[str | bytes | os.PathLike[str]] | None = None,
relative_paths: bool | None = None,
) -> builtins.list[str]:
"""Repair worktree administrative files.

Args:
paths: Optional list of worktree paths to repair. If None, repairs
connections from the main repository to all linked worktrees.
relative_paths: If True, use relative paths for gitdir references.
If None, check worktree.useRelativePaths config or preserve existing format

Returns:
List of repaired worktree paths
"""
return repair_worktree(self._repo, paths=paths)
return repair_worktree(self._repo, paths=paths, relative_paths=relative_paths)

def __iter__(self) -> Iterator[WorkTreeInfo]:
"""Iterate over all worktrees."""
Expand Down Expand Up @@ -951,6 +1024,7 @@ def add_worktree(
force: bool = False,
detach: bool = False,
exist_ok: bool = False,
relative_paths: bool | None = None,
) -> Repo:
"""Add a new worktree to the repository.

Expand All @@ -962,6 +1036,8 @@ def add_worktree(
force: Force creation even if branch is already checked out elsewhere
detach: Detach HEAD in the new worktree
exist_ok: If True, do not raise an error if the directory already exists
relative_paths: If True, use relative paths for gitdir references.
If None, check worktree.useRelativePaths config (defaults to False)

Returns:
The newly created worktree repository
Expand All @@ -975,6 +1051,9 @@ def add_worktree(
if isinstance(path, bytes):
path = os.fsdecode(path)

# Determine whether to use relative paths
use_relative = _should_use_relative_paths(repo, relative_paths)

# Check if path already exists
if os.path.exists(path) and not exist_ok:
raise ValueError(f"Path already exists: {path}")
Expand Down Expand Up @@ -1020,7 +1099,9 @@ def add_worktree(

# Initialize the worktree
identifier = os.path.basename(path)
wt_repo = RepoClass._init_new_working_directory(path, repo, identifier=identifier)
wt_repo = RepoClass._init_new_working_directory(
path, repo, identifier=identifier, relative_paths=use_relative
)

# Set HEAD appropriately
if detach:
Expand Down Expand Up @@ -1250,13 +1331,16 @@ def move_worktree(
repo: Repo,
old_path: str | bytes | os.PathLike[str],
new_path: str | bytes | os.PathLike[str],
relative_paths: bool | None = None,
) -> None:
"""Move a worktree to a new location.

Args:
repo: The main repository
old_path: Current path of the worktree
new_path: New path for the worktree
relative_paths: If True, use relative paths for gitdir references.
If None, check worktree.useRelativePaths config or preserve existing format

Raises:
ValueError: If the worktree doesn't exist or new path already exists
Expand All @@ -1280,19 +1364,37 @@ def move_worktree(
worktree_id = _find_worktree_id(repo, old_path)
worktree_control_dir = os.path.join(repo.controldir(), WORKTREES, worktree_id)

# Read existing path to check format
existing_path = None
try:
with open(os.path.join(worktree_control_dir, GITDIR), "rb") as f:
existing_path = f.read().strip()
except (FileNotFoundError, PermissionError):
pass

# Determine whether to use relative paths
use_relative = _should_use_relative_paths(repo, relative_paths, existing_path)

# Move the actual worktree directory
shutil.move(old_path, new_path)

# Update the gitdir file in the worktree
gitdir_file = os.path.join(new_path, ".git")
gitdir_file_abs = os.path.abspath(os.path.join(new_path, ".git"))

# Compute the path to write
gitdir_path = _compute_gitdir_path(
repo, gitdir_file_abs, worktree_control_dir, use_relative
)

# Update the gitdir pointer in the control directory
with open(os.path.join(worktree_control_dir, GITDIR), "wb") as f:
f.write(os.fsencode(gitdir_file) + b"\n")
f.write(os.fsencode(gitdir_path) + b"\n")


def repair_worktree(
repo: Repo, paths: Sequence[str | bytes | os.PathLike[str]] | None = None
repo: Repo,
paths: Sequence[str | bytes | os.PathLike[str]] | None = None,
relative_paths: bool | None = None,
) -> list[str]:
"""Repair worktree administrative files.

Expand All @@ -1303,6 +1405,8 @@ def repair_worktree(
repo: The main repository
paths: Optional list of worktree paths to repair. If None, repairs
connections from the main repository to all linked worktrees.
relative_paths: If True, use relative paths for gitdir references.
If None, check worktree.useRelativePaths config or preserve existing format

Returns:
List of repaired worktree paths
Expand Down Expand Up @@ -1348,9 +1452,27 @@ def repair_worktree(
# Update the gitdir file in the worktree control directory
gitdir_pointer = os.path.join(worktree_control_path, GITDIR)
if os.path.exists(gitdir_pointer):
# Read existing path to check format
existing_path = None
try:
with open(gitdir_pointer, "rb") as f:
existing_path = f.read().strip()
except (FileNotFoundError, PermissionError):
pass

# Determine which format to use for this worktree
use_relative = _should_use_relative_paths(
repo, relative_paths, existing_path
)

# Compute the path to write
gitdir_path_to_write = _compute_gitdir_path(
repo, gitdir_file, worktree_control_path, use_relative
)

# Update to point to the current location
with open(gitdir_pointer, "wb") as f:
f.write(os.fsencode(gitdir_file) + b"\n")
f.write(os.fsencode(gitdir_path_to_write) + b"\n")
repaired.append(path_str)
else:
# Repair from main repository to all linked worktrees
Expand Down Expand Up @@ -1393,11 +1515,24 @@ def repair_worktree(
if os.path.abspath(current_pointer) != os.path.abspath(
expected_pointer
):
# Determine which format to use
use_relative = _should_use_relative_paths(
repo, relative_paths, gitdir_contents
)

# Compute the path to write (from worktree to control dir)
pointer_to_write = _compute_gitdir_path(
repo,
worktree_control_path,
old_worktree_path,
use_relative,
)

# Update the .git file to point to the correct location
with open(old_gitdir_location, "wb") as wf:
wf.write(
b"gitdir: "
+ os.fsencode(worktree_control_path)
+ os.fsencode(pointer_to_write)
+ b"\n"
)
repaired.append(old_worktree_path)
Expand Down
Loading
Loading