Skip to content

VCS API Reference

vcs

VCS CLI wrapper — zero dependencies, stdlib only, Python 3.10+.

Part of zerodep: https://github.com/Oaklight/zerodep Copyright (c) 2026 Peng Ding. MIT License.

Provides a uniform Python interface to version-control systems (Git, Mercurial, Jujutsu) by shelling out to their CLI binaries. Each backend implements the :class:VCSBackend protocol so callers can write VCS-agnostic tooling.

Quick start::

from vcs import detect

repo = detect(".")
if repo is not None:
    print(repo.name, repo.current_branch())
    for fs in repo.status():
        print(fs.status, fs.path)

Requires Python 3.10+.

VCSError

Bases: Exception

Base exception for all VCS operations.

Source code in vcs/vcs.py
class VCSError(Exception):
    """Base exception for all VCS operations."""

BinaryNotFoundError

Bases: VCSError

Raised when the VCS binary cannot be located.

Attributes:

Name Type Description
binary_name

Name of the binary that was not found.

Source code in vcs/vcs.py
class BinaryNotFoundError(VCSError):
    """Raised when the VCS binary cannot be located.

    Attributes:
        binary_name: Name of the binary that was not found.
    """

    def __init__(self, binary_name: str) -> None:
        self.binary_name = binary_name
        super().__init__(f"VCS binary not found: {binary_name}")

CommandError

Bases: VCSError

Raised when a VCS command exits with an unexpected return code.

Attributes:

Name Type Description
command

The full command list that was executed.

returncode

Process exit code.

stderr

Captured standard-error output.

timeout

Timeout value in seconds if this was a timeout, else None.

Source code in vcs/vcs.py
class CommandError(VCSError):
    """Raised when a VCS command exits with an unexpected return code.

    Attributes:
        command: The full command list that was executed.
        returncode: Process exit code.
        stderr: Captured standard-error output.
        timeout: Timeout value in seconds if this was a timeout, else ``None``.
    """

    def __init__(
        self,
        command: list[str],
        returncode: int,
        stderr: str,
        *,
        timeout: float | None = None,
    ) -> None:
        self.command = command
        self.returncode = returncode
        self.stderr = stderr
        self.timeout = timeout
        cmd_str = " ".join(command)
        if timeout is not None:
            msg = f"Command timed out after {timeout}s: {cmd_str}"
        else:
            msg = f"Command failed (rc={returncode}): {cmd_str}\n{stderr.rstrip()}"
        super().__init__(msg)

NotARepoError

Bases: VCSError

Raised when the given path is not inside a repository.

Attributes:

Name Type Description
path

Filesystem path that was tested.

Source code in vcs/vcs.py
class NotARepoError(VCSError):
    """Raised when the given path is not inside a repository.

    Attributes:
        path: Filesystem path that was tested.
    """

    def __init__(self, path: str) -> None:
        self.path = path
        super().__init__(f"Not a repository: {path}")

FileStatus dataclass

Status of a single file in the working tree.

Attributes:

Name Type Description
path str

Relative path of the file.

status str

Single-character status code (e.g. 'M', 'A', 'D', 'R', '?', '!').

original_path str | None

For renames/copies, the path before the operation.

Source code in vcs/vcs.py
@dataclasses.dataclass(frozen=True, slots=True)
class FileStatus:
    """Status of a single file in the working tree.

    Attributes:
        path: Relative path of the file.
        status: Single-character status code (e.g. ``'M'``, ``'A'``,
            ``'D'``, ``'R'``, ``'?'``, ``'!'``).
        original_path: For renames/copies, the path before the operation.
    """

    path: str
    status: str
    original_path: str | None = None

Commit dataclass

Metadata for a single commit.

Attributes:

Name Type Description
hash str

Full commit hash.

short_hash str

Abbreviated commit hash.

author str

Author name.

date str

Commit date in ISO 8601 format.

message str

First line of the commit message.

Source code in vcs/vcs.py
@dataclasses.dataclass(frozen=True, slots=True)
class Commit:
    """Metadata for a single commit.

    Attributes:
        hash: Full commit hash.
        short_hash: Abbreviated commit hash.
        author: Author name.
        date: Commit date in ISO 8601 format.
        message: First line of the commit message.
    """

    hash: str
    short_hash: str
    author: str
    date: str
    message: str

BlameLine dataclass

Annotation for a single source line.

Attributes:

Name Type Description
commit str

Commit hash that last changed this line.

author str

Author of the commit.

date str

Commit date.

line_no int

1-based line number.

content str

Actual text content of the line.

Source code in vcs/vcs.py
@dataclasses.dataclass(frozen=True, slots=True)
class BlameLine:
    """Annotation for a single source line.

    Attributes:
        commit: Commit hash that last changed this line.
        author: Author of the commit.
        date: Commit date.
        line_no: 1-based line number.
        content: Actual text content of the line.
    """

    commit: str
    author: str
    date: str
    line_no: int
    content: str

WorkspaceInfo dataclass

Information about a workspace (Git worktree / Jujutsu workspace).

Attributes:

Name Type Description
path str

Absolute path to the workspace directory.

head str

HEAD commit hash.

branch str | None

Branch name (Git) or bookmark (Jujutsu), None for detached HEAD or unknown.

is_main bool

Whether this is the main/default workspace.

Source code in vcs/vcs.py
@dataclasses.dataclass(frozen=True, slots=True)
class WorkspaceInfo:
    """Information about a workspace (Git worktree / Jujutsu workspace).

    Attributes:
        path: Absolute path to the workspace directory.
        head: HEAD commit hash.
        branch: Branch name (Git) or bookmark (Jujutsu), ``None``
            for detached HEAD or unknown.
        is_main: Whether this is the main/default workspace.
    """

    path: str
    head: str
    branch: str | None = None
    is_main: bool = False

VCSBackend

Bases: Protocol

Protocol that every VCS backend must satisfy.

Source code in vcs/vcs.py
@runtime_checkable
class VCSBackend(Protocol):
    """Protocol that every VCS backend must satisfy."""

    @property
    def name(self) -> str:
        """Short name of the VCS (e.g. ``"git"``)."""
        ...

    def is_repo(self, path: str) -> bool:
        """Return ``True`` if *path* is inside a repository."""
        ...

    def diff(self, *paths: str, staged: bool = False) -> str:
        """Return a unified diff of uncommitted changes.

        Args:
            *paths: Restrict diff to these paths.
            staged: If ``True``, diff only staged (index) changes.
        """
        ...

    def diff_files(self, path_a: str, path_b: str) -> str:
        """Return a diff between two arbitrary files.

        Args:
            path_a: First file path.
            path_b: Second file path.
        """
        ...

    def apply(self, patch: str) -> None:
        """Apply a unified diff patch to the working tree.

        Args:
            patch: Patch text (unified diff format).
        """
        ...

    def status(self) -> list[FileStatus]:
        """Return the list of changed files in the working tree."""
        ...

    def log(self, n: int = 10) -> list[Commit]:
        """Return the last *n* commits.

        Args:
            n: Maximum number of commits to return.
        """
        ...

    def blame(self, path: str) -> list[BlameLine]:
        """Return per-line annotation for a file.

        Args:
            path: Path to the file (relative to repo root).
        """
        ...

    def current_branch(self) -> str:
        """Return the name of the current branch or bookmark."""
        ...

    def merge_file(self, base: str, ours: str, theirs: str) -> str:
        """Three-way merge of file contents.

        Args:
            base: Common-ancestor file content.
            ours: Content from the first branch.
            theirs: Content from the second branch.

        Returns:
            Merged file content (may contain conflict markers).
        """
        ...

    def workspace_add(
        self,
        path: str,
        *,
        branch: str | None = None,
        rev: str | None = None,
    ) -> str:
        """Create a new workspace at *path*.

        Args:
            path: Directory for the new workspace.
            branch: Branch to create (Git) or bookmark (Jujutsu).
            rev: Starting revision/commit.

        Returns:
            Absolute path to the created workspace.
        """
        ...

    def workspace_remove(self, path: str, *, force: bool = False) -> None:
        """Remove a workspace.

        Args:
            path: Path to the workspace directory.
            force: Force removal even with uncommitted changes.
        """
        ...

    def workspace_list(self) -> list[WorkspaceInfo]:
        """List all workspaces.

        Returns:
            List of :class:`WorkspaceInfo` entries.
        """
        ...

    def branches(self) -> list[str]:
        """List all branch names or bookmarks.

        Returns:
            List of branch/bookmark name strings.
        """
        ...

    def create_branch(self, name: str, *, rev: str | None = None) -> None:
        """Create a new branch or bookmark.

        Args:
            name: Branch/bookmark name.
            rev: Starting point (defaults to current HEAD).
        """
        ...

    def switch(self, target: str) -> None:
        """Switch to a branch or revision.

        Args:
            target: Branch name or revision identifier.
        """
        ...

    def commit(self, message: str, *, paths: list[str] | None = None) -> str:
        """Create a commit/change.

        Args:
            message: Commit message.
            paths: Files to stage and commit (Git-specific).

        Returns:
            Commit hash of the new commit.
        """
        ...

    def rev_parse(self, rev: str) -> str:
        """Resolve a revision string to a full commit hash.

        Args:
            rev: Revision string (branch name, tag, ``"HEAD"``, etc.).

        Returns:
            Full commit hash.
        """
        ...

name property

Short name of the VCS (e.g. "git").

is_repo(path)

Return True if path is inside a repository.

Source code in vcs/vcs.py
def is_repo(self, path: str) -> bool:
    """Return ``True`` if *path* is inside a repository."""
    ...

diff(*paths, staged=False)

Return a unified diff of uncommitted changes.

Parameters:

Name Type Description Default
*paths str

Restrict diff to these paths.

()
staged bool

If True, diff only staged (index) changes.

False
Source code in vcs/vcs.py
def diff(self, *paths: str, staged: bool = False) -> str:
    """Return a unified diff of uncommitted changes.

    Args:
        *paths: Restrict diff to these paths.
        staged: If ``True``, diff only staged (index) changes.
    """
    ...

diff_files(path_a, path_b)

Return a diff between two arbitrary files.

Parameters:

Name Type Description Default
path_a str

First file path.

required
path_b str

Second file path.

required
Source code in vcs/vcs.py
def diff_files(self, path_a: str, path_b: str) -> str:
    """Return a diff between two arbitrary files.

    Args:
        path_a: First file path.
        path_b: Second file path.
    """
    ...

apply(patch)

Apply a unified diff patch to the working tree.

Parameters:

Name Type Description Default
patch str

Patch text (unified diff format).

required
Source code in vcs/vcs.py
def apply(self, patch: str) -> None:
    """Apply a unified diff patch to the working tree.

    Args:
        patch: Patch text (unified diff format).
    """
    ...

status()

Return the list of changed files in the working tree.

Source code in vcs/vcs.py
def status(self) -> list[FileStatus]:
    """Return the list of changed files in the working tree."""
    ...

log(n=10)

Return the last n commits.

Parameters:

Name Type Description Default
n int

Maximum number of commits to return.

10
Source code in vcs/vcs.py
def log(self, n: int = 10) -> list[Commit]:
    """Return the last *n* commits.

    Args:
        n: Maximum number of commits to return.
    """
    ...

blame(path)

Return per-line annotation for a file.

Parameters:

Name Type Description Default
path str

Path to the file (relative to repo root).

required
Source code in vcs/vcs.py
def blame(self, path: str) -> list[BlameLine]:
    """Return per-line annotation for a file.

    Args:
        path: Path to the file (relative to repo root).
    """
    ...

current_branch()

Return the name of the current branch or bookmark.

Source code in vcs/vcs.py
def current_branch(self) -> str:
    """Return the name of the current branch or bookmark."""
    ...

merge_file(base, ours, theirs)

Three-way merge of file contents.

Parameters:

Name Type Description Default
base str

Common-ancestor file content.

required
ours str

Content from the first branch.

required
theirs str

Content from the second branch.

required

Returns:

Type Description
str

Merged file content (may contain conflict markers).

Source code in vcs/vcs.py
def merge_file(self, base: str, ours: str, theirs: str) -> str:
    """Three-way merge of file contents.

    Args:
        base: Common-ancestor file content.
        ours: Content from the first branch.
        theirs: Content from the second branch.

    Returns:
        Merged file content (may contain conflict markers).
    """
    ...

workspace_add(path, *, branch=None, rev=None)

Create a new workspace at path.

Parameters:

Name Type Description Default
path str

Directory for the new workspace.

required
branch str | None

Branch to create (Git) or bookmark (Jujutsu).

None
rev str | None

Starting revision/commit.

None

Returns:

Type Description
str

Absolute path to the created workspace.

Source code in vcs/vcs.py
def workspace_add(
    self,
    path: str,
    *,
    branch: str | None = None,
    rev: str | None = None,
) -> str:
    """Create a new workspace at *path*.

    Args:
        path: Directory for the new workspace.
        branch: Branch to create (Git) or bookmark (Jujutsu).
        rev: Starting revision/commit.

    Returns:
        Absolute path to the created workspace.
    """
    ...

workspace_remove(path, *, force=False)

Remove a workspace.

Parameters:

Name Type Description Default
path str

Path to the workspace directory.

required
force bool

Force removal even with uncommitted changes.

False
Source code in vcs/vcs.py
def workspace_remove(self, path: str, *, force: bool = False) -> None:
    """Remove a workspace.

    Args:
        path: Path to the workspace directory.
        force: Force removal even with uncommitted changes.
    """
    ...

workspace_list()

List all workspaces.

Returns:

Type Description
list[WorkspaceInfo]

List of :class:WorkspaceInfo entries.

Source code in vcs/vcs.py
def workspace_list(self) -> list[WorkspaceInfo]:
    """List all workspaces.

    Returns:
        List of :class:`WorkspaceInfo` entries.
    """
    ...

branches()

List all branch names or bookmarks.

Returns:

Type Description
list[str]

List of branch/bookmark name strings.

Source code in vcs/vcs.py
def branches(self) -> list[str]:
    """List all branch names or bookmarks.

    Returns:
        List of branch/bookmark name strings.
    """
    ...

create_branch(name, *, rev=None)

Create a new branch or bookmark.

Parameters:

Name Type Description Default
name str

Branch/bookmark name.

required
rev str | None

Starting point (defaults to current HEAD).

None
Source code in vcs/vcs.py
def create_branch(self, name: str, *, rev: str | None = None) -> None:
    """Create a new branch or bookmark.

    Args:
        name: Branch/bookmark name.
        rev: Starting point (defaults to current HEAD).
    """
    ...

switch(target)

Switch to a branch or revision.

Parameters:

Name Type Description Default
target str

Branch name or revision identifier.

required
Source code in vcs/vcs.py
def switch(self, target: str) -> None:
    """Switch to a branch or revision.

    Args:
        target: Branch name or revision identifier.
    """
    ...

commit(message, *, paths=None)

Create a commit/change.

Parameters:

Name Type Description Default
message str

Commit message.

required
paths list[str] | None

Files to stage and commit (Git-specific).

None

Returns:

Type Description
str

Commit hash of the new commit.

Source code in vcs/vcs.py
def commit(self, message: str, *, paths: list[str] | None = None) -> str:
    """Create a commit/change.

    Args:
        message: Commit message.
        paths: Files to stage and commit (Git-specific).

    Returns:
        Commit hash of the new commit.
    """
    ...

rev_parse(rev)

Resolve a revision string to a full commit hash.

Parameters:

Name Type Description Default
rev str

Revision string (branch name, tag, "HEAD", etc.).

required

Returns:

Type Description
str

Full commit hash.

Source code in vcs/vcs.py
def rev_parse(self, rev: str) -> str:
    """Resolve a revision string to a full commit hash.

    Args:
        rev: Revision string (branch name, tag, ``"HEAD"``, etc.).

    Returns:
        Full commit hash.
    """
    ...

Git

Git CLI backend.

Parameters:

Name Type Description Default
repo_path str

Path to the repository (defaults to ".").

'.'
binary str | None

Explicit path to the git binary; discovered automatically if None.

None
encoding str

Text encoding for command I/O.

'utf-8'
timeout float

Default subprocess timeout in seconds.

30.0
Source code in vcs/vcs.py
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
class Git:
    """Git CLI backend.

    Args:
        repo_path: Path to the repository (defaults to ``"."``).
        binary: Explicit path to the ``git`` binary; discovered
            automatically if ``None``.
        encoding: Text encoding for command I/O.
        timeout: Default subprocess timeout in seconds.
    """

    def __init__(
        self,
        repo_path: str = ".",
        *,
        binary: str | None = None,
        encoding: str = "utf-8",
        timeout: float = 30.0,
    ) -> None:
        self._binary = binary or _find_binary("git")
        self._repo = os.path.abspath(repo_path)
        self._encoding = encoding
        self._timeout = timeout

    # -- helpers --

    def _git(
        self,
        *args: str,
        allowed_returncodes: tuple[int, ...] = (0,),
        input: str | None = None,  # noqa: A002
    ) -> subprocess.CompletedProcess[str]:
        """Run a git sub-command inside the repository.

        Args:
            *args: Arguments after ``git``.
            allowed_returncodes: Acceptable exit codes.
            input: Text to pass via stdin.

        Returns:
            Completed process result.
        """
        cmd = [self._binary, *args]
        return _run(
            cmd,
            cwd=self._repo,
            input=input,
            timeout=self._timeout,
            encoding=self._encoding,
            allowed_returncodes=allowed_returncodes,
        )

    # -- protocol implementation --

    @property
    def name(self) -> str:
        """Return ``"git"``."""
        return "git"

    def is_repo(self, path: str) -> bool:
        """Check whether *path* is inside a git repository.

        Args:
            path: Directory to test.

        Returns:
            ``True`` if *path* is a git working tree or bare repo.
        """
        git_dir = os.path.join(path, ".git")
        if os.path.isdir(git_dir) or os.path.isfile(git_dir):
            return True
        try:
            _run(
                [self._binary, "-C", path, "rev-parse", "--git-dir"],
                timeout=self._timeout,
                encoding=self._encoding,
            )
            return True
        except (VCSError, OSError):
            return False

    def diff(self, *paths: str, staged: bool = False) -> str:
        """Return a unified diff of uncommitted changes.

        Args:
            *paths: Restrict diff to these paths.
            staged: If ``True``, show only staged changes.

        Returns:
            Diff text (may be empty).
        """
        cmd: list[str] = ["diff"]
        if staged:
            cmd.append("--staged")
        if paths:
            cmd.append("--")
            cmd.extend(paths)
        result = self._git(*cmd, allowed_returncodes=(0, 1))
        return result.stdout

    def diff_files(self, path_a: str, path_b: str) -> str:
        """Return a diff between two arbitrary files.

        Args:
            path_a: First file path.
            path_b: Second file path.

        Returns:
            Diff text.
        """
        result = self._git(
            "diff", "--no-index", "--", path_a, path_b, allowed_returncodes=(0, 1)
        )
        return result.stdout

    def apply(self, patch: str) -> None:
        """Apply a unified diff patch via ``git apply``.

        Args:
            patch: Patch text.
        """
        self._git("apply", "-", input=patch)

    def status(self) -> list[FileStatus]:
        """Return the list of changed files via ``git status --porcelain``.

        Returns:
            List of :class:`FileStatus` entries.
        """
        result = self._git("status", "--porcelain=v1")
        entries: list[FileStatus] = []
        for line in result.stdout.splitlines():
            if len(line) < 4:
                continue
            xy = line[:2]
            rest = line[3:]
            # Pick the most informative status character:
            # index status (X) or work-tree status (Y).
            status_char = xy[0] if xy[0] != " " else xy[1]
            if status_char == "R" and " -> " in rest:
                old, new = rest.split(" -> ", 1)
                entries.append(FileStatus(path=new, status="R", original_path=old))
            elif " -> " in rest and xy[0] == "R":
                old, new = rest.split(" -> ", 1)
                entries.append(FileStatus(path=new, status="R", original_path=old))
            else:
                entries.append(FileStatus(path=rest, status=status_char))
        return entries

    def log(self, n: int = 10) -> list[Commit]:
        """Return the last *n* commits.

        Args:
            n: Maximum number of commits.

        Returns:
            List of :class:`Commit` entries, newest first.
        """
        fmt = "%H%n%h%n%an%n%aI%n%s"
        result = self._git("log", f"--format={fmt}", "-n", str(n))
        lines = result.stdout.strip().splitlines()
        commits: list[Commit] = []
        for i in range(0, len(lines) - 4, 5):
            commits.append(
                Commit(
                    hash=lines[i],
                    short_hash=lines[i + 1],
                    author=lines[i + 2],
                    date=lines[i + 3],
                    message=lines[i + 4],
                )
            )
        return commits

    def blame(self, path: str) -> list[BlameLine]:
        """Return per-line blame annotation via ``git blame --porcelain``.

        Args:
            path: File path relative to the repository root.

        Returns:
            List of :class:`BlameLine` entries, one per source line.
        """
        result = self._git("blame", "--porcelain", path)
        blame_lines: list[BlameLine] = []
        current_hash = ""
        current_author = ""
        current_date = ""
        current_lineno = 0
        for raw in result.stdout.splitlines():
            # Header line: <hash> <orig-line> <final-line> [<num-lines>]
            m = re.match(r"^([0-9a-f]{40})\s+\d+\s+(\d+)", raw)
            if m:
                current_hash = m.group(1)
                current_lineno = int(m.group(2))
                continue
            if raw.startswith("author "):
                current_author = raw[7:]
            elif raw.startswith("author-time "):
                current_date = raw[12:]
            elif raw.startswith("\t"):
                blame_lines.append(
                    BlameLine(
                        commit=current_hash,
                        author=current_author,
                        date=current_date,
                        line_no=current_lineno,
                        content=raw[1:],
                    )
                )
        return blame_lines

    def current_branch(self) -> str:
        """Return the current branch name.

        Falls back to the short HEAD hash when in detached-HEAD state.

        Returns:
            Branch name or abbreviated commit hash.
        """
        result = self._git("branch", "--show-current")
        branch = result.stdout.strip()
        if branch:
            return branch
        # Detached HEAD — return short hash
        result = self._git("rev-parse", "--short", "HEAD")
        return result.stdout.strip()

    def merge_file(self, base: str, ours: str, theirs: str) -> str:
        """Three-way merge of file contents via ``git merge-file``.

        Writes the three versions to temporary files, runs
        ``git merge-file -p``, and returns the merged output.  Conflict
        markers are included when the merge is non-clean.

        Args:
            base: Common-ancestor file content.
            ours: Content from the first branch.
            theirs: Content from the second branch.

        Returns:
            Merged file content (may contain conflict markers).
        """
        tmp_base = tmp_ours = tmp_theirs = None
        try:
            tmp_base = tempfile.NamedTemporaryFile(
                mode="w",
                suffix=".base",
                delete=False,
                encoding=self._encoding,
            )
            tmp_base.write(base)
            tmp_base.close()

            tmp_ours = tempfile.NamedTemporaryFile(
                mode="w",
                suffix=".ours",
                delete=False,
                encoding=self._encoding,
            )
            tmp_ours.write(ours)
            tmp_ours.close()

            tmp_theirs = tempfile.NamedTemporaryFile(
                mode="w",
                suffix=".theirs",
                delete=False,
                encoding=self._encoding,
            )
            tmp_theirs.write(theirs)
            tmp_theirs.close()

            result = self._git(
                "merge-file",
                "-p",
                tmp_ours.name,
                tmp_base.name,
                tmp_theirs.name,
                allowed_returncodes=(0, 1),
            )
            return result.stdout
        finally:
            for tmp in (tmp_base, tmp_ours, tmp_theirs):
                if tmp is not None:
                    # Tier 3: best-effort silent — temp file cleanup
                    try:
                        os.unlink(tmp.name)
                    except OSError:
                        pass

    # -- workspace / branch / commit operations --

    def workspace_add(
        self,
        path: str,
        *,
        branch: str | None = None,
        rev: str | None = None,
    ) -> str:
        """Create a new workspace via ``git worktree add``.

        Args:
            path: Directory for the new workspace.
            branch: If given, create this branch in the new workspace.
            rev: Starting commit-ish.

        Returns:
            Absolute path to the created workspace.
        """
        abs_path = os.path.abspath(path)
        cmd: list[str] = ["worktree", "add"]
        if branch:
            cmd.extend(["-b", branch])
        cmd.append(abs_path)
        if rev:
            cmd.append(rev)
        self._git(*cmd)
        return abs_path

    def workspace_remove(self, path: str, *, force: bool = False) -> None:
        """Remove a workspace via ``git worktree remove``.

        Args:
            path: Path to the workspace directory.
            force: Force removal even with uncommitted changes.
        """
        abs_path = os.path.abspath(path)
        cmd: list[str] = ["worktree", "remove"]
        if force:
            cmd.append("--force")
        cmd.append(abs_path)
        self._git(*cmd)

    def workspace_list(self) -> list[WorkspaceInfo]:
        """List all workspaces via ``git worktree list --porcelain``.

        Returns:
            List of :class:`WorkspaceInfo` entries.
        """
        result = self._git("worktree", "list", "--porcelain")
        workspaces: list[WorkspaceInfo] = []
        path = ""
        head = ""
        branch: str | None = None
        for line in result.stdout.splitlines():
            if line.startswith("worktree "):
                # Flush previous entry
                if path:
                    workspaces.append(
                        WorkspaceInfo(
                            path=path,
                            head=head,
                            branch=branch,
                            is_main=len(workspaces) == 0,
                        )
                    )
                path = line[9:]
                head = ""
                branch = None
            elif line.startswith("HEAD "):
                head = line[5:]
            elif line.startswith("branch "):
                branch = line[7:].removeprefix("refs/heads/")
            elif line == "detached":
                branch = None
        # Flush last entry
        if path:
            workspaces.append(
                WorkspaceInfo(
                    path=path,
                    head=head,
                    branch=branch,
                    is_main=len(workspaces) == 0,
                )
            )
        return workspaces

    def branches(self) -> list[str]:
        """List all branches via ``git branch``.

        Returns:
            List of branch name strings.
        """
        result = self._git("branch", "--format=%(refname:short)")
        return [b.strip() for b in result.stdout.splitlines() if b.strip()]

    def create_branch(self, name: str, *, rev: str | None = None) -> None:
        """Create a new branch via ``git branch``.

        Args:
            name: Branch name.
            rev: Starting commit-ish (defaults to HEAD).
        """
        cmd: list[str] = ["branch", name]
        if rev:
            cmd.append(rev)
        self._git(*cmd)

    def switch(self, target: str) -> None:
        """Switch to a branch or revision via ``git switch``.

        Falls back to ``git switch --detach`` for non-branch revisions.

        Args:
            target: Branch name or revision identifier.
        """
        try:
            self._git("switch", target)
        except CommandError:
            self._git("switch", "--detach", target)

    def commit(self, message: str, *, paths: list[str] | None = None) -> str:
        """Create a commit via ``git commit``.

        If *paths* is given, stages those files first.  Otherwise commits
        whatever is already staged.

        Args:
            message: Commit message.
            paths: Files to stage before committing.

        Returns:
            Full commit hash of the new commit.
        """
        if paths:
            self._git("add", "--", *paths)
        self._git("commit", "-m", message)
        result = self._git("rev-parse", "HEAD")
        return result.stdout.strip()

    def rev_parse(self, rev: str) -> str:
        """Resolve a revision string via ``git rev-parse``.

        Args:
            rev: Revision string.

        Returns:
            Full commit hash.
        """
        result = self._git("rev-parse", rev)
        return result.stdout.strip()

name property

Return "git".

is_repo(path)

Check whether path is inside a git repository.

Parameters:

Name Type Description Default
path str

Directory to test.

required

Returns:

Type Description
bool

True if path is a git working tree or bare repo.

Source code in vcs/vcs.py
def is_repo(self, path: str) -> bool:
    """Check whether *path* is inside a git repository.

    Args:
        path: Directory to test.

    Returns:
        ``True`` if *path* is a git working tree or bare repo.
    """
    git_dir = os.path.join(path, ".git")
    if os.path.isdir(git_dir) or os.path.isfile(git_dir):
        return True
    try:
        _run(
            [self._binary, "-C", path, "rev-parse", "--git-dir"],
            timeout=self._timeout,
            encoding=self._encoding,
        )
        return True
    except (VCSError, OSError):
        return False

diff(*paths, staged=False)

Return a unified diff of uncommitted changes.

Parameters:

Name Type Description Default
*paths str

Restrict diff to these paths.

()
staged bool

If True, show only staged changes.

False

Returns:

Type Description
str

Diff text (may be empty).

Source code in vcs/vcs.py
def diff(self, *paths: str, staged: bool = False) -> str:
    """Return a unified diff of uncommitted changes.

    Args:
        *paths: Restrict diff to these paths.
        staged: If ``True``, show only staged changes.

    Returns:
        Diff text (may be empty).
    """
    cmd: list[str] = ["diff"]
    if staged:
        cmd.append("--staged")
    if paths:
        cmd.append("--")
        cmd.extend(paths)
    result = self._git(*cmd, allowed_returncodes=(0, 1))
    return result.stdout

diff_files(path_a, path_b)

Return a diff between two arbitrary files.

Parameters:

Name Type Description Default
path_a str

First file path.

required
path_b str

Second file path.

required

Returns:

Type Description
str

Diff text.

Source code in vcs/vcs.py
def diff_files(self, path_a: str, path_b: str) -> str:
    """Return a diff between two arbitrary files.

    Args:
        path_a: First file path.
        path_b: Second file path.

    Returns:
        Diff text.
    """
    result = self._git(
        "diff", "--no-index", "--", path_a, path_b, allowed_returncodes=(0, 1)
    )
    return result.stdout

apply(patch)

Apply a unified diff patch via git apply.

Parameters:

Name Type Description Default
patch str

Patch text.

required
Source code in vcs/vcs.py
def apply(self, patch: str) -> None:
    """Apply a unified diff patch via ``git apply``.

    Args:
        patch: Patch text.
    """
    self._git("apply", "-", input=patch)

status()

Return the list of changed files via git status --porcelain.

Returns:

Type Description
list[FileStatus]

List of :class:FileStatus entries.

Source code in vcs/vcs.py
def status(self) -> list[FileStatus]:
    """Return the list of changed files via ``git status --porcelain``.

    Returns:
        List of :class:`FileStatus` entries.
    """
    result = self._git("status", "--porcelain=v1")
    entries: list[FileStatus] = []
    for line in result.stdout.splitlines():
        if len(line) < 4:
            continue
        xy = line[:2]
        rest = line[3:]
        # Pick the most informative status character:
        # index status (X) or work-tree status (Y).
        status_char = xy[0] if xy[0] != " " else xy[1]
        if status_char == "R" and " -> " in rest:
            old, new = rest.split(" -> ", 1)
            entries.append(FileStatus(path=new, status="R", original_path=old))
        elif " -> " in rest and xy[0] == "R":
            old, new = rest.split(" -> ", 1)
            entries.append(FileStatus(path=new, status="R", original_path=old))
        else:
            entries.append(FileStatus(path=rest, status=status_char))
    return entries

log(n=10)

Return the last n commits.

Parameters:

Name Type Description Default
n int

Maximum number of commits.

10

Returns:

Type Description
list[Commit]

List of :class:Commit entries, newest first.

Source code in vcs/vcs.py
def log(self, n: int = 10) -> list[Commit]:
    """Return the last *n* commits.

    Args:
        n: Maximum number of commits.

    Returns:
        List of :class:`Commit` entries, newest first.
    """
    fmt = "%H%n%h%n%an%n%aI%n%s"
    result = self._git("log", f"--format={fmt}", "-n", str(n))
    lines = result.stdout.strip().splitlines()
    commits: list[Commit] = []
    for i in range(0, len(lines) - 4, 5):
        commits.append(
            Commit(
                hash=lines[i],
                short_hash=lines[i + 1],
                author=lines[i + 2],
                date=lines[i + 3],
                message=lines[i + 4],
            )
        )
    return commits

blame(path)

Return per-line blame annotation via git blame --porcelain.

Parameters:

Name Type Description Default
path str

File path relative to the repository root.

required

Returns:

Type Description
list[BlameLine]

List of :class:BlameLine entries, one per source line.

Source code in vcs/vcs.py
def blame(self, path: str) -> list[BlameLine]:
    """Return per-line blame annotation via ``git blame --porcelain``.

    Args:
        path: File path relative to the repository root.

    Returns:
        List of :class:`BlameLine` entries, one per source line.
    """
    result = self._git("blame", "--porcelain", path)
    blame_lines: list[BlameLine] = []
    current_hash = ""
    current_author = ""
    current_date = ""
    current_lineno = 0
    for raw in result.stdout.splitlines():
        # Header line: <hash> <orig-line> <final-line> [<num-lines>]
        m = re.match(r"^([0-9a-f]{40})\s+\d+\s+(\d+)", raw)
        if m:
            current_hash = m.group(1)
            current_lineno = int(m.group(2))
            continue
        if raw.startswith("author "):
            current_author = raw[7:]
        elif raw.startswith("author-time "):
            current_date = raw[12:]
        elif raw.startswith("\t"):
            blame_lines.append(
                BlameLine(
                    commit=current_hash,
                    author=current_author,
                    date=current_date,
                    line_no=current_lineno,
                    content=raw[1:],
                )
            )
    return blame_lines

current_branch()

Return the current branch name.

Falls back to the short HEAD hash when in detached-HEAD state.

Returns:

Type Description
str

Branch name or abbreviated commit hash.

Source code in vcs/vcs.py
def current_branch(self) -> str:
    """Return the current branch name.

    Falls back to the short HEAD hash when in detached-HEAD state.

    Returns:
        Branch name or abbreviated commit hash.
    """
    result = self._git("branch", "--show-current")
    branch = result.stdout.strip()
    if branch:
        return branch
    # Detached HEAD — return short hash
    result = self._git("rev-parse", "--short", "HEAD")
    return result.stdout.strip()

merge_file(base, ours, theirs)

Three-way merge of file contents via git merge-file.

Writes the three versions to temporary files, runs git merge-file -p, and returns the merged output. Conflict markers are included when the merge is non-clean.

Parameters:

Name Type Description Default
base str

Common-ancestor file content.

required
ours str

Content from the first branch.

required
theirs str

Content from the second branch.

required

Returns:

Type Description
str

Merged file content (may contain conflict markers).

Source code in vcs/vcs.py
def merge_file(self, base: str, ours: str, theirs: str) -> str:
    """Three-way merge of file contents via ``git merge-file``.

    Writes the three versions to temporary files, runs
    ``git merge-file -p``, and returns the merged output.  Conflict
    markers are included when the merge is non-clean.

    Args:
        base: Common-ancestor file content.
        ours: Content from the first branch.
        theirs: Content from the second branch.

    Returns:
        Merged file content (may contain conflict markers).
    """
    tmp_base = tmp_ours = tmp_theirs = None
    try:
        tmp_base = tempfile.NamedTemporaryFile(
            mode="w",
            suffix=".base",
            delete=False,
            encoding=self._encoding,
        )
        tmp_base.write(base)
        tmp_base.close()

        tmp_ours = tempfile.NamedTemporaryFile(
            mode="w",
            suffix=".ours",
            delete=False,
            encoding=self._encoding,
        )
        tmp_ours.write(ours)
        tmp_ours.close()

        tmp_theirs = tempfile.NamedTemporaryFile(
            mode="w",
            suffix=".theirs",
            delete=False,
            encoding=self._encoding,
        )
        tmp_theirs.write(theirs)
        tmp_theirs.close()

        result = self._git(
            "merge-file",
            "-p",
            tmp_ours.name,
            tmp_base.name,
            tmp_theirs.name,
            allowed_returncodes=(0, 1),
        )
        return result.stdout
    finally:
        for tmp in (tmp_base, tmp_ours, tmp_theirs):
            if tmp is not None:
                # Tier 3: best-effort silent — temp file cleanup
                try:
                    os.unlink(tmp.name)
                except OSError:
                    pass

workspace_add(path, *, branch=None, rev=None)

Create a new workspace via git worktree add.

Parameters:

Name Type Description Default
path str

Directory for the new workspace.

required
branch str | None

If given, create this branch in the new workspace.

None
rev str | None

Starting commit-ish.

None

Returns:

Type Description
str

Absolute path to the created workspace.

Source code in vcs/vcs.py
def workspace_add(
    self,
    path: str,
    *,
    branch: str | None = None,
    rev: str | None = None,
) -> str:
    """Create a new workspace via ``git worktree add``.

    Args:
        path: Directory for the new workspace.
        branch: If given, create this branch in the new workspace.
        rev: Starting commit-ish.

    Returns:
        Absolute path to the created workspace.
    """
    abs_path = os.path.abspath(path)
    cmd: list[str] = ["worktree", "add"]
    if branch:
        cmd.extend(["-b", branch])
    cmd.append(abs_path)
    if rev:
        cmd.append(rev)
    self._git(*cmd)
    return abs_path

workspace_remove(path, *, force=False)

Remove a workspace via git worktree remove.

Parameters:

Name Type Description Default
path str

Path to the workspace directory.

required
force bool

Force removal even with uncommitted changes.

False
Source code in vcs/vcs.py
def workspace_remove(self, path: str, *, force: bool = False) -> None:
    """Remove a workspace via ``git worktree remove``.

    Args:
        path: Path to the workspace directory.
        force: Force removal even with uncommitted changes.
    """
    abs_path = os.path.abspath(path)
    cmd: list[str] = ["worktree", "remove"]
    if force:
        cmd.append("--force")
    cmd.append(abs_path)
    self._git(*cmd)

workspace_list()

List all workspaces via git worktree list --porcelain.

Returns:

Type Description
list[WorkspaceInfo]

List of :class:WorkspaceInfo entries.

Source code in vcs/vcs.py
def workspace_list(self) -> list[WorkspaceInfo]:
    """List all workspaces via ``git worktree list --porcelain``.

    Returns:
        List of :class:`WorkspaceInfo` entries.
    """
    result = self._git("worktree", "list", "--porcelain")
    workspaces: list[WorkspaceInfo] = []
    path = ""
    head = ""
    branch: str | None = None
    for line in result.stdout.splitlines():
        if line.startswith("worktree "):
            # Flush previous entry
            if path:
                workspaces.append(
                    WorkspaceInfo(
                        path=path,
                        head=head,
                        branch=branch,
                        is_main=len(workspaces) == 0,
                    )
                )
            path = line[9:]
            head = ""
            branch = None
        elif line.startswith("HEAD "):
            head = line[5:]
        elif line.startswith("branch "):
            branch = line[7:].removeprefix("refs/heads/")
        elif line == "detached":
            branch = None
    # Flush last entry
    if path:
        workspaces.append(
            WorkspaceInfo(
                path=path,
                head=head,
                branch=branch,
                is_main=len(workspaces) == 0,
            )
        )
    return workspaces

branches()

List all branches via git branch.

Returns:

Type Description
list[str]

List of branch name strings.

Source code in vcs/vcs.py
def branches(self) -> list[str]:
    """List all branches via ``git branch``.

    Returns:
        List of branch name strings.
    """
    result = self._git("branch", "--format=%(refname:short)")
    return [b.strip() for b in result.stdout.splitlines() if b.strip()]

create_branch(name, *, rev=None)

Create a new branch via git branch.

Parameters:

Name Type Description Default
name str

Branch name.

required
rev str | None

Starting commit-ish (defaults to HEAD).

None
Source code in vcs/vcs.py
def create_branch(self, name: str, *, rev: str | None = None) -> None:
    """Create a new branch via ``git branch``.

    Args:
        name: Branch name.
        rev: Starting commit-ish (defaults to HEAD).
    """
    cmd: list[str] = ["branch", name]
    if rev:
        cmd.append(rev)
    self._git(*cmd)

switch(target)

Switch to a branch or revision via git switch.

Falls back to git switch --detach for non-branch revisions.

Parameters:

Name Type Description Default
target str

Branch name or revision identifier.

required
Source code in vcs/vcs.py
def switch(self, target: str) -> None:
    """Switch to a branch or revision via ``git switch``.

    Falls back to ``git switch --detach`` for non-branch revisions.

    Args:
        target: Branch name or revision identifier.
    """
    try:
        self._git("switch", target)
    except CommandError:
        self._git("switch", "--detach", target)

commit(message, *, paths=None)

Create a commit via git commit.

If paths is given, stages those files first. Otherwise commits whatever is already staged.

Parameters:

Name Type Description Default
message str

Commit message.

required
paths list[str] | None

Files to stage before committing.

None

Returns:

Type Description
str

Full commit hash of the new commit.

Source code in vcs/vcs.py
def commit(self, message: str, *, paths: list[str] | None = None) -> str:
    """Create a commit via ``git commit``.

    If *paths* is given, stages those files first.  Otherwise commits
    whatever is already staged.

    Args:
        message: Commit message.
        paths: Files to stage before committing.

    Returns:
        Full commit hash of the new commit.
    """
    if paths:
        self._git("add", "--", *paths)
    self._git("commit", "-m", message)
    result = self._git("rev-parse", "HEAD")
    return result.stdout.strip()

rev_parse(rev)

Resolve a revision string via git rev-parse.

Parameters:

Name Type Description Default
rev str

Revision string.

required

Returns:

Type Description
str

Full commit hash.

Source code in vcs/vcs.py
def rev_parse(self, rev: str) -> str:
    """Resolve a revision string via ``git rev-parse``.

    Args:
        rev: Revision string.

    Returns:
        Full commit hash.
    """
    result = self._git("rev-parse", rev)
    return result.stdout.strip()

Mercurial

Mercurial (hg) CLI backend.

Parameters:

Name Type Description Default
repo_path str

Path to the repository (defaults to ".").

'.'
binary str | None

Explicit path to the hg binary; discovered automatically if None.

None
encoding str

Text encoding for command I/O.

'utf-8'
timeout float

Default subprocess timeout in seconds.

30.0
merge_func Callable[[str, str, str], Any] | None | _Unset

Three-way merge callable (base, ours, theirs) -> result. Defaults to _UNSET (auto-discover sibling diff module). Pass None to disable merge_file(), or a custom callable whose return value has a .content attribute (or is a plain str).

_UNSET
Source code in vcs/vcs.py
class Mercurial:
    """Mercurial (``hg``) CLI backend.

    Args:
        repo_path: Path to the repository (defaults to ``"."``).
        binary: Explicit path to the ``hg`` binary; discovered
            automatically if ``None``.
        encoding: Text encoding for command I/O.
        timeout: Default subprocess timeout in seconds.
        merge_func: Three-way merge callable ``(base, ours, theirs) -> result``.
            Defaults to ``_UNSET`` (auto-discover sibling ``diff`` module).
            Pass ``None`` to disable ``merge_file()``, or a custom callable
            whose return value has a ``.content`` attribute (or is a plain
            ``str``).
    """

    def __init__(
        self,
        repo_path: str = ".",
        *,
        binary: str | None = None,
        encoding: str = "utf-8",
        timeout: float = 30.0,
        merge_func: Callable[[str, str, str], Any] | None | _Unset = _UNSET,
    ) -> None:
        self._binary = binary or _find_binary("hg")
        self._repo = os.path.abspath(repo_path)
        self._encoding = encoding
        self._timeout = timeout
        self._merge_func = merge_func

    def _hg(
        self,
        *args: str,
        allowed_returncodes: tuple[int, ...] = (0,),
        input: str | None = None,  # noqa: A002
    ) -> subprocess.CompletedProcess[str]:
        """Run a Mercurial sub-command inside the repository."""
        cmd = [self._binary, *args]
        return _run(
            cmd,
            cwd=self._repo,
            input=input,
            timeout=self._timeout,
            encoding=self._encoding,
            allowed_returncodes=allowed_returncodes,
        )

    @property
    def name(self) -> str:
        """Return ``"hg"``."""
        return "hg"

    def is_repo(self, path: str) -> bool:
        """Check whether *path* is inside a Mercurial repository.

        Args:
            path: Directory to test.

        Returns:
            ``True`` if *path* contains an ``.hg`` directory.
        """
        return os.path.isdir(os.path.join(path, ".hg"))

    def diff(self, *paths: str, staged: bool = False) -> str:
        """Return a unified diff of uncommitted changes.

        Args:
            *paths: Restrict diff to these paths.
            staged: Ignored — Mercurial has no staging area.

        Returns:
            Diff text.
        """
        cmd: list[str] = ["diff"]
        if paths:
            cmd.extend(paths)
        result = self._hg(*cmd)
        return result.stdout

    def diff_files(self, path_a: str, path_b: str) -> str:
        """Not supported by Mercurial CLI.

        Raises:
            NotImplementedError: Always.
        """
        raise NotImplementedError("hg does not support diffing arbitrary files")

    def apply(self, patch: str) -> None:
        """Apply a patch via ``hg import --no-commit``.

        Args:
            patch: Patch text.
        """
        self._hg("import", "--no-commit", "-", input=patch)

    def status(self) -> list[FileStatus]:
        """Return the list of changed files via ``hg status``.

        Returns:
            List of :class:`FileStatus` entries.
        """
        result = self._hg("status")
        entries: list[FileStatus] = []
        for line in result.stdout.splitlines():
            if len(line) < 3:
                continue
            status_char = line[0]
            path = line[2:]
            entries.append(FileStatus(path=path, status=status_char))
        return entries

    def log(self, n: int = 10) -> list[Commit]:
        """Return the last *n* commits.

        Args:
            n: Maximum number of commits.

        Returns:
            List of :class:`Commit` entries, newest first.
        """
        template = (
            "{node}\\n{short(node)}\\n{author|user}\\n"
            "{date|isodate}\\n{desc|firstline}\\n\\x00"
        )
        result = self._hg("log", "--template", template, "-l", str(n))
        commits: list[Commit] = []
        for block in result.stdout.split("\x00"):
            block = block.strip()
            if not block:
                continue
            lines = block.splitlines()
            if len(lines) < 5:
                continue
            commits.append(
                Commit(
                    hash=lines[0],
                    short_hash=lines[1],
                    author=lines[2],
                    date=lines[3],
                    message=lines[4],
                )
            )
        return commits

    def blame(self, path: str) -> list[BlameLine]:
        """Return per-line blame via ``hg annotate``.

        Args:
            path: File path relative to the repository root.

        Returns:
            List of :class:`BlameLine` entries.
        """
        result = self._hg("annotate", "-u", "-d", "-n", "-c", path)
        blame_lines: list[BlameLine] = []
        line_no = 0
        for raw in result.stdout.splitlines():
            line_no += 1
            # Format: <changeset> <user> <date> <lineno>: <content>
            m = re.match(
                r"^\s*([0-9a-f]+)\s+(.+?)\s+"
                r"(\S+ \S+(?: [+-]\d+)?)\s+(\d+):\s?(.*)",
                raw,
            )
            if m:
                blame_lines.append(
                    BlameLine(
                        commit=m.group(1),
                        author=m.group(2).strip(),
                        date=m.group(3),
                        line_no=int(m.group(4)),
                        content=m.group(5),
                    )
                )
        return blame_lines

    def current_branch(self) -> str:
        """Return the current Mercurial branch name.

        Returns:
            Branch name string.
        """
        result = self._hg("branch")
        return result.stdout.strip()

    def merge_file(self, base: str, ours: str, theirs: str) -> str:
        """Three-way merge using the sibling ``diff`` module.

        Uses an injected ``merge_func`` if provided at construction time,
        otherwise falls back to sibling ``diff.merge3`` auto-discovery.

        Args:
            base: Common-ancestor file content.
            ours: Content from the first branch.
            theirs: Content from the second branch.

        Returns:
            Merged file content (may contain conflict markers).

        Raises:
            NotImplementedError: If the sibling ``diff`` module is
                not available and no ``merge_func`` was injected,
                or if ``merge_func=None`` was passed explicitly.
        """
        merge3 = self._resolve_merge_func()
        result = merge3(base, ours, theirs)
        return result.content if hasattr(result, "content") else result

    def _resolve_merge_func(self) -> Callable[[str, str, str], Any]:
        """Return the merge callable, resolving sibling fallback if needed."""
        if isinstance(self._merge_func, _Unset):
            return _load_diff_merge3()
        if self._merge_func is None:
            raise NotImplementedError(
                "merge_file disabled: merge_func=None was passed explicitly"
            )
        return self._merge_func

    def workspace_add(
        self,
        path: str,
        *,
        branch: str | None = None,
        rev: str | None = None,
    ) -> str:
        """Not supported by Mercurial.

        Raises:
            NotImplementedError: Always.
        """
        raise NotImplementedError("hg does not support workspaces")

    def workspace_remove(self, path: str, *, force: bool = False) -> None:
        """Not supported by Mercurial.

        Raises:
            NotImplementedError: Always.
        """
        raise NotImplementedError("hg does not support workspaces")

    def workspace_list(self) -> list[WorkspaceInfo]:
        """Not supported by Mercurial.

        Raises:
            NotImplementedError: Always.
        """
        raise NotImplementedError("hg does not support workspaces")

    def branches(self) -> list[str]:
        """Not supported — Mercurial branches have different semantics.

        Raises:
            NotImplementedError: Always.
        """
        raise NotImplementedError("hg branches have different semantics; not supported")

    def create_branch(self, name: str, *, rev: str | None = None) -> None:
        """Not supported — Mercurial branches have different semantics.

        Raises:
            NotImplementedError: Always.
        """
        raise NotImplementedError("hg branches have different semantics; not supported")

    def switch(self, target: str) -> None:
        """Not supported by Mercurial.

        Raises:
            NotImplementedError: Always.
        """
        raise NotImplementedError("hg does not support switch")

    def commit(self, message: str, *, paths: list[str] | None = None) -> str:
        """Not supported by Mercurial.

        Raises:
            NotImplementedError: Always.
        """
        raise NotImplementedError("hg commit is not supported via this interface")

    def rev_parse(self, rev: str) -> str:
        """Not supported by Mercurial.

        Raises:
            NotImplementedError: Always.
        """
        raise NotImplementedError("hg does not support rev-parse via this interface")

name property

Return "hg".

is_repo(path)

Check whether path is inside a Mercurial repository.

Parameters:

Name Type Description Default
path str

Directory to test.

required

Returns:

Type Description
bool

True if path contains an .hg directory.

Source code in vcs/vcs.py
def is_repo(self, path: str) -> bool:
    """Check whether *path* is inside a Mercurial repository.

    Args:
        path: Directory to test.

    Returns:
        ``True`` if *path* contains an ``.hg`` directory.
    """
    return os.path.isdir(os.path.join(path, ".hg"))

diff(*paths, staged=False)

Return a unified diff of uncommitted changes.

Parameters:

Name Type Description Default
*paths str

Restrict diff to these paths.

()
staged bool

Ignored — Mercurial has no staging area.

False

Returns:

Type Description
str

Diff text.

Source code in vcs/vcs.py
def diff(self, *paths: str, staged: bool = False) -> str:
    """Return a unified diff of uncommitted changes.

    Args:
        *paths: Restrict diff to these paths.
        staged: Ignored — Mercurial has no staging area.

    Returns:
        Diff text.
    """
    cmd: list[str] = ["diff"]
    if paths:
        cmd.extend(paths)
    result = self._hg(*cmd)
    return result.stdout

diff_files(path_a, path_b)

Not supported by Mercurial CLI.

Raises:

Type Description
NotImplementedError

Always.

Source code in vcs/vcs.py
def diff_files(self, path_a: str, path_b: str) -> str:
    """Not supported by Mercurial CLI.

    Raises:
        NotImplementedError: Always.
    """
    raise NotImplementedError("hg does not support diffing arbitrary files")

apply(patch)

Apply a patch via hg import --no-commit.

Parameters:

Name Type Description Default
patch str

Patch text.

required
Source code in vcs/vcs.py
def apply(self, patch: str) -> None:
    """Apply a patch via ``hg import --no-commit``.

    Args:
        patch: Patch text.
    """
    self._hg("import", "--no-commit", "-", input=patch)

status()

Return the list of changed files via hg status.

Returns:

Type Description
list[FileStatus]

List of :class:FileStatus entries.

Source code in vcs/vcs.py
def status(self) -> list[FileStatus]:
    """Return the list of changed files via ``hg status``.

    Returns:
        List of :class:`FileStatus` entries.
    """
    result = self._hg("status")
    entries: list[FileStatus] = []
    for line in result.stdout.splitlines():
        if len(line) < 3:
            continue
        status_char = line[0]
        path = line[2:]
        entries.append(FileStatus(path=path, status=status_char))
    return entries

log(n=10)

Return the last n commits.

Parameters:

Name Type Description Default
n int

Maximum number of commits.

10

Returns:

Type Description
list[Commit]

List of :class:Commit entries, newest first.

Source code in vcs/vcs.py
def log(self, n: int = 10) -> list[Commit]:
    """Return the last *n* commits.

    Args:
        n: Maximum number of commits.

    Returns:
        List of :class:`Commit` entries, newest first.
    """
    template = (
        "{node}\\n{short(node)}\\n{author|user}\\n"
        "{date|isodate}\\n{desc|firstline}\\n\\x00"
    )
    result = self._hg("log", "--template", template, "-l", str(n))
    commits: list[Commit] = []
    for block in result.stdout.split("\x00"):
        block = block.strip()
        if not block:
            continue
        lines = block.splitlines()
        if len(lines) < 5:
            continue
        commits.append(
            Commit(
                hash=lines[0],
                short_hash=lines[1],
                author=lines[2],
                date=lines[3],
                message=lines[4],
            )
        )
    return commits

blame(path)

Return per-line blame via hg annotate.

Parameters:

Name Type Description Default
path str

File path relative to the repository root.

required

Returns:

Type Description
list[BlameLine]

List of :class:BlameLine entries.

Source code in vcs/vcs.py
def blame(self, path: str) -> list[BlameLine]:
    """Return per-line blame via ``hg annotate``.

    Args:
        path: File path relative to the repository root.

    Returns:
        List of :class:`BlameLine` entries.
    """
    result = self._hg("annotate", "-u", "-d", "-n", "-c", path)
    blame_lines: list[BlameLine] = []
    line_no = 0
    for raw in result.stdout.splitlines():
        line_no += 1
        # Format: <changeset> <user> <date> <lineno>: <content>
        m = re.match(
            r"^\s*([0-9a-f]+)\s+(.+?)\s+"
            r"(\S+ \S+(?: [+-]\d+)?)\s+(\d+):\s?(.*)",
            raw,
        )
        if m:
            blame_lines.append(
                BlameLine(
                    commit=m.group(1),
                    author=m.group(2).strip(),
                    date=m.group(3),
                    line_no=int(m.group(4)),
                    content=m.group(5),
                )
            )
    return blame_lines

current_branch()

Return the current Mercurial branch name.

Returns:

Type Description
str

Branch name string.

Source code in vcs/vcs.py
def current_branch(self) -> str:
    """Return the current Mercurial branch name.

    Returns:
        Branch name string.
    """
    result = self._hg("branch")
    return result.stdout.strip()

merge_file(base, ours, theirs)

Three-way merge using the sibling diff module.

Uses an injected merge_func if provided at construction time, otherwise falls back to sibling diff.merge3 auto-discovery.

Parameters:

Name Type Description Default
base str

Common-ancestor file content.

required
ours str

Content from the first branch.

required
theirs str

Content from the second branch.

required

Returns:

Type Description
str

Merged file content (may contain conflict markers).

Raises:

Type Description
NotImplementedError

If the sibling diff module is not available and no merge_func was injected, or if merge_func=None was passed explicitly.

Source code in vcs/vcs.py
def merge_file(self, base: str, ours: str, theirs: str) -> str:
    """Three-way merge using the sibling ``diff`` module.

    Uses an injected ``merge_func`` if provided at construction time,
    otherwise falls back to sibling ``diff.merge3`` auto-discovery.

    Args:
        base: Common-ancestor file content.
        ours: Content from the first branch.
        theirs: Content from the second branch.

    Returns:
        Merged file content (may contain conflict markers).

    Raises:
        NotImplementedError: If the sibling ``diff`` module is
            not available and no ``merge_func`` was injected,
            or if ``merge_func=None`` was passed explicitly.
    """
    merge3 = self._resolve_merge_func()
    result = merge3(base, ours, theirs)
    return result.content if hasattr(result, "content") else result

workspace_add(path, *, branch=None, rev=None)

Not supported by Mercurial.

Raises:

Type Description
NotImplementedError

Always.

Source code in vcs/vcs.py
def workspace_add(
    self,
    path: str,
    *,
    branch: str | None = None,
    rev: str | None = None,
) -> str:
    """Not supported by Mercurial.

    Raises:
        NotImplementedError: Always.
    """
    raise NotImplementedError("hg does not support workspaces")

workspace_remove(path, *, force=False)

Not supported by Mercurial.

Raises:

Type Description
NotImplementedError

Always.

Source code in vcs/vcs.py
def workspace_remove(self, path: str, *, force: bool = False) -> None:
    """Not supported by Mercurial.

    Raises:
        NotImplementedError: Always.
    """
    raise NotImplementedError("hg does not support workspaces")

workspace_list()

Not supported by Mercurial.

Raises:

Type Description
NotImplementedError

Always.

Source code in vcs/vcs.py
def workspace_list(self) -> list[WorkspaceInfo]:
    """Not supported by Mercurial.

    Raises:
        NotImplementedError: Always.
    """
    raise NotImplementedError("hg does not support workspaces")

branches()

Not supported — Mercurial branches have different semantics.

Raises:

Type Description
NotImplementedError

Always.

Source code in vcs/vcs.py
def branches(self) -> list[str]:
    """Not supported — Mercurial branches have different semantics.

    Raises:
        NotImplementedError: Always.
    """
    raise NotImplementedError("hg branches have different semantics; not supported")

create_branch(name, *, rev=None)

Not supported — Mercurial branches have different semantics.

Raises:

Type Description
NotImplementedError

Always.

Source code in vcs/vcs.py
def create_branch(self, name: str, *, rev: str | None = None) -> None:
    """Not supported — Mercurial branches have different semantics.

    Raises:
        NotImplementedError: Always.
    """
    raise NotImplementedError("hg branches have different semantics; not supported")

switch(target)

Not supported by Mercurial.

Raises:

Type Description
NotImplementedError

Always.

Source code in vcs/vcs.py
def switch(self, target: str) -> None:
    """Not supported by Mercurial.

    Raises:
        NotImplementedError: Always.
    """
    raise NotImplementedError("hg does not support switch")

commit(message, *, paths=None)

Not supported by Mercurial.

Raises:

Type Description
NotImplementedError

Always.

Source code in vcs/vcs.py
def commit(self, message: str, *, paths: list[str] | None = None) -> str:
    """Not supported by Mercurial.

    Raises:
        NotImplementedError: Always.
    """
    raise NotImplementedError("hg commit is not supported via this interface")

rev_parse(rev)

Not supported by Mercurial.

Raises:

Type Description
NotImplementedError

Always.

Source code in vcs/vcs.py
def rev_parse(self, rev: str) -> str:
    """Not supported by Mercurial.

    Raises:
        NotImplementedError: Always.
    """
    raise NotImplementedError("hg does not support rev-parse via this interface")

Jujutsu

Jujutsu (jj) CLI backend.

Parameters:

Name Type Description Default
repo_path str

Path to the repository (defaults to ".").

'.'
binary str | None

Explicit path to the jj binary; discovered automatically if None.

None
encoding str

Text encoding for command I/O.

'utf-8'
timeout float

Default subprocess timeout in seconds.

30.0
merge_func Callable[[str, str, str], Any] | None | _Unset

Three-way merge callable (base, ours, theirs) -> result. Defaults to _UNSET (auto-discover sibling diff module). Pass None to disable merge_file(), or a custom callable whose return value has a .content attribute (or is a plain str).

_UNSET
Source code in vcs/vcs.py
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
class Jujutsu:
    """Jujutsu (``jj``) CLI backend.

    Args:
        repo_path: Path to the repository (defaults to ``"."``).
        binary: Explicit path to the ``jj`` binary; discovered
            automatically if ``None``.
        encoding: Text encoding for command I/O.
        timeout: Default subprocess timeout in seconds.
        merge_func: Three-way merge callable ``(base, ours, theirs) -> result``.
            Defaults to ``_UNSET`` (auto-discover sibling ``diff`` module).
            Pass ``None`` to disable ``merge_file()``, or a custom callable
            whose return value has a ``.content`` attribute (or is a plain
            ``str``).
    """

    def __init__(
        self,
        repo_path: str = ".",
        *,
        binary: str | None = None,
        encoding: str = "utf-8",
        timeout: float = 30.0,
        merge_func: Callable[[str, str, str], Any] | None | _Unset = _UNSET,
    ) -> None:
        self._binary = binary or _find_binary("jj")
        self._repo = os.path.abspath(repo_path)
        self._encoding = encoding
        self._timeout = timeout
        self._merge_func = merge_func

    def _jj(
        self,
        *args: str,
        allowed_returncodes: tuple[int, ...] = (0,),
        input: str | None = None,  # noqa: A002
    ) -> subprocess.CompletedProcess[str]:
        """Run a Jujutsu sub-command inside the repository."""
        cmd = [self._binary, *args]
        return _run(
            cmd,
            cwd=self._repo,
            input=input,
            timeout=self._timeout,
            encoding=self._encoding,
            allowed_returncodes=allowed_returncodes,
        )

    @property
    def name(self) -> str:
        """Return ``"jj"``."""
        return "jj"

    def is_repo(self, path: str) -> bool:
        """Check whether *path* is inside a Jujutsu repository.

        Args:
            path: Directory to test.

        Returns:
            ``True`` if *path* contains a ``.jj`` directory.
        """
        return os.path.isdir(os.path.join(path, ".jj"))

    def diff(self, *paths: str, staged: bool = False) -> str:
        """Return a diff of uncommitted changes.

        Jujutsu has no staging area, so *staged* is ignored.

        Args:
            *paths: Restrict diff to these paths.
            staged: Ignored.

        Returns:
            Diff text.
        """
        cmd: list[str] = ["diff"]
        if paths:
            cmd.extend(paths)
        result = self._jj(*cmd)
        return result.stdout

    def diff_files(self, path_a: str, path_b: str) -> str:
        """Not supported by Jujutsu CLI.

        Raises:
            NotImplementedError: Always.
        """
        raise NotImplementedError("jj does not support diffing arbitrary files")

    def apply(self, patch: str) -> None:
        """Not supported by Jujutsu CLI.

        Raises:
            NotImplementedError: Always.
        """
        raise NotImplementedError("jj does not support applying patches via CLI")

    def status(self) -> list[FileStatus]:
        """Return the list of changed files via ``jj status``.

        Returns:
            List of :class:`FileStatus` entries.
        """
        result = self._jj("status")
        entries: list[FileStatus] = []
        # jj status output: "M path" / "A path" / "D path" / etc.
        for line in result.stdout.splitlines():
            m = re.match(r"^([MADR?!])\s+(.+)$", line)
            if m:
                entries.append(FileStatus(path=m.group(2), status=m.group(1)))
        return entries

    def log(self, n: int = 10) -> list[Commit]:
        """Return the last *n* commits.

        Args:
            n: Maximum number of commits.

        Returns:
            List of :class:`Commit` entries, newest first.
        """
        template = (
            'commit_id ++ "\\n" ++ '
            'commit_id.short() ++ "\\n" ++ '
            'if(author, author.name(), "") ++ "\\n" ++ '
            'if(author, author.timestamp(), "") ++ "\\n" ++ '
            'if(description, description.first_line(), "") ++ "\\n" ++ '
            '"\\x00"'
        )
        result = self._jj("log", "-n", str(n), "--no-graph", "-T", template)
        commits: list[Commit] = []
        for block in result.stdout.split("\x00"):
            block = block.strip()
            if not block:
                continue
            lines = block.splitlines()
            if len(lines) < 5:
                continue
            commits.append(
                Commit(
                    hash=lines[0],
                    short_hash=lines[1],
                    author=lines[2],
                    date=lines[3],
                    message=lines[4],
                )
            )
        return commits

    def blame(self, path: str) -> list[BlameLine]:
        """Return per-line annotation via ``jj file annotate``.

        Args:
            path: File path relative to the repository root.

        Returns:
            List of :class:`BlameLine` entries.
        """
        result = self._jj("file", "annotate", path)
        blame_lines: list[BlameLine] = []
        line_no = 0
        for raw in result.stdout.splitlines():
            line_no += 1
            # jj file annotate format: <change_id> <committer> <date> <line>
            m = re.match(r"^(\S+)\s+(\S+)\s+(\S+)\s?(.*)", raw)
            if m:
                blame_lines.append(
                    BlameLine(
                        commit=m.group(1),
                        author=m.group(2),
                        date=m.group(3),
                        line_no=line_no,
                        content=m.group(4),
                    )
                )
        return blame_lines

    def current_branch(self) -> str:
        """Return the current bookmark or change ID.

        Returns:
            First bookmark pointing to ``@``, or the change ID.
        """
        result = self._jj("bookmark", "list", "--pointing-to", "@")
        first = result.stdout.strip().splitlines()
        if first and first[0].strip():
            # Bookmark line format: "name: <change-id>"
            return first[0].split(":")[0].strip()
        # No bookmark — return change ID
        result = self._jj("log", "-r", "@", "--no-graph", "-T", "change_id.short()")
        return result.stdout.strip()

    def merge_file(self, base: str, ours: str, theirs: str) -> str:
        """Three-way merge using the sibling ``diff`` module.

        Uses an injected ``merge_func`` if provided at construction time,
        otherwise falls back to sibling ``diff.merge3`` auto-discovery.

        Args:
            base: Common-ancestor file content.
            ours: Content from the first branch.
            theirs: Content from the second branch.

        Returns:
            Merged file content (may contain conflict markers).

        Raises:
            NotImplementedError: If the sibling ``diff`` module is
                not available and no ``merge_func`` was injected,
                or if ``merge_func=None`` was passed explicitly.
        """
        merge3 = self._resolve_merge_func()
        result = merge3(base, ours, theirs)
        return result.content if hasattr(result, "content") else result

    def _resolve_merge_func(self) -> Callable[[str, str, str], Any]:
        """Return the merge callable, resolving sibling fallback if needed."""
        if isinstance(self._merge_func, _Unset):
            return _load_diff_merge3()
        if self._merge_func is None:
            raise NotImplementedError(
                "merge_file disabled: merge_func=None was passed explicitly"
            )
        return self._merge_func

    # -- workspace / bookmark / commit operations --

    def workspace_add(
        self,
        path: str,
        *,
        branch: str | None = None,
        rev: str | None = None,
    ) -> str:
        """Create a new workspace via ``jj workspace add``.

        The workspace name defaults to the basename of *path* (Jujutsu
        convention).  If *branch* is given, a bookmark is created in the
        new workspace pointing to its working-copy change.

        Args:
            path: Directory for the new workspace.
            branch: If given, create a bookmark in the new workspace.
            rev: Starting revision.

        Returns:
            Absolute path to the created workspace.
        """
        abs_path = os.path.abspath(path)
        cmd: list[str] = ["workspace", "add", abs_path]
        if rev:
            cmd.extend(["-r", rev])
        self._jj(*cmd)
        if branch:
            # Create a bookmark in the new workspace context
            _run(
                [self._binary, "bookmark", "create", branch],
                cwd=abs_path,
                timeout=self._timeout,
                encoding=self._encoding,
            )
        return abs_path

    def workspace_remove(self, path: str, *, force: bool = False) -> None:
        """Remove a workspace via ``jj workspace forget``.

        Derives the workspace name from the directory basename (Jujutsu
        default naming convention), then removes the directory.

        Args:
            path: Path to the workspace directory.
            force: Unused — ``jj workspace forget`` always succeeds for
                registered workspaces.
        """
        abs_path = os.path.abspath(path)
        ws_name = os.path.basename(abs_path)
        self._jj("workspace", "forget", ws_name)
        if os.path.isdir(abs_path):
            shutil.rmtree(abs_path)

    def workspace_list(self) -> list[WorkspaceInfo]:
        """List all workspaces via ``jj workspace list``.

        Only the default workspace has a known path (``repo_path``);
        other workspace paths are not exposed by ``jj workspace list``.

        Returns:
            List of :class:`WorkspaceInfo` entries.
        """
        result = self._jj("workspace", "list")
        workspaces: list[WorkspaceInfo] = []
        for line in result.stdout.splitlines():
            # Format: "name: change_id_short commit_id_short ..."
            m = re.match(r"^(\S+):\s+(\S+)\s+(\S+)", line)
            if m:
                ws_name = m.group(1)
                commit_short = m.group(3)
                path = self._repo if ws_name == "default" else ""
                workspaces.append(
                    WorkspaceInfo(
                        path=path,
                        head=commit_short,
                        branch=None,
                        is_main=ws_name == "default",
                    )
                )
        return workspaces

    def branches(self) -> list[str]:
        """List all bookmarks via ``jj bookmark list``.

        Returns:
            List of bookmark name strings.
        """
        result = self._jj("bookmark", "list")
        names: list[str] = []
        for line in result.stdout.splitlines():
            # Format: "name: change_id commit_id ..."
            m = re.match(r"^(\S+):", line)
            if m:
                names.append(m.group(1))
        return names

    def create_branch(self, name: str, *, rev: str | None = None) -> None:
        """Create a bookmark via ``jj bookmark create``.

        Args:
            name: Bookmark name.
            rev: Target revision (defaults to ``@``).
        """
        cmd: list[str] = ["bookmark", "create", name]
        if rev:
            cmd.extend(["-r", rev])
        self._jj(*cmd)

    def switch(self, target: str) -> None:
        """Switch working copy via ``jj new``.

        Creates a new change on top of *target*, analogous to how
        ``git switch`` positions the working copy for new commits.

        Args:
            target: Bookmark name or revision identifier.
        """
        self._jj("new", target)

    def commit(self, message: str, *, paths: list[str] | None = None) -> str:
        """Finalize the current change via ``jj commit``.

        The *paths* argument is ignored — Jujutsu auto-tracks all
        working-copy changes.

        Args:
            message: Change description.
            paths: Ignored.

        Returns:
            Full commit hash of the finalized change.
        """
        self._jj("commit", "-m", message)
        # The committed change is now @- (parent of current)
        result = self._jj("log", "-r", "@-", "--no-graph", "-T", "commit_id")
        return result.stdout.strip()

    def rev_parse(self, rev: str) -> str:
        """Resolve a revision string to a full commit hash.

        Args:
            rev: Revision string (bookmark, change ID, etc.).

        Returns:
            Full commit hash.
        """
        result = self._jj("log", "-r", rev, "--no-graph", "-T", "commit_id")
        return result.stdout.strip()

name property

Return "jj".

is_repo(path)

Check whether path is inside a Jujutsu repository.

Parameters:

Name Type Description Default
path str

Directory to test.

required

Returns:

Type Description
bool

True if path contains a .jj directory.

Source code in vcs/vcs.py
def is_repo(self, path: str) -> bool:
    """Check whether *path* is inside a Jujutsu repository.

    Args:
        path: Directory to test.

    Returns:
        ``True`` if *path* contains a ``.jj`` directory.
    """
    return os.path.isdir(os.path.join(path, ".jj"))

diff(*paths, staged=False)

Return a diff of uncommitted changes.

Jujutsu has no staging area, so staged is ignored.

Parameters:

Name Type Description Default
*paths str

Restrict diff to these paths.

()
staged bool

Ignored.

False

Returns:

Type Description
str

Diff text.

Source code in vcs/vcs.py
def diff(self, *paths: str, staged: bool = False) -> str:
    """Return a diff of uncommitted changes.

    Jujutsu has no staging area, so *staged* is ignored.

    Args:
        *paths: Restrict diff to these paths.
        staged: Ignored.

    Returns:
        Diff text.
    """
    cmd: list[str] = ["diff"]
    if paths:
        cmd.extend(paths)
    result = self._jj(*cmd)
    return result.stdout

diff_files(path_a, path_b)

Not supported by Jujutsu CLI.

Raises:

Type Description
NotImplementedError

Always.

Source code in vcs/vcs.py
def diff_files(self, path_a: str, path_b: str) -> str:
    """Not supported by Jujutsu CLI.

    Raises:
        NotImplementedError: Always.
    """
    raise NotImplementedError("jj does not support diffing arbitrary files")

apply(patch)

Not supported by Jujutsu CLI.

Raises:

Type Description
NotImplementedError

Always.

Source code in vcs/vcs.py
def apply(self, patch: str) -> None:
    """Not supported by Jujutsu CLI.

    Raises:
        NotImplementedError: Always.
    """
    raise NotImplementedError("jj does not support applying patches via CLI")

status()

Return the list of changed files via jj status.

Returns:

Type Description
list[FileStatus]

List of :class:FileStatus entries.

Source code in vcs/vcs.py
def status(self) -> list[FileStatus]:
    """Return the list of changed files via ``jj status``.

    Returns:
        List of :class:`FileStatus` entries.
    """
    result = self._jj("status")
    entries: list[FileStatus] = []
    # jj status output: "M path" / "A path" / "D path" / etc.
    for line in result.stdout.splitlines():
        m = re.match(r"^([MADR?!])\s+(.+)$", line)
        if m:
            entries.append(FileStatus(path=m.group(2), status=m.group(1)))
    return entries

log(n=10)

Return the last n commits.

Parameters:

Name Type Description Default
n int

Maximum number of commits.

10

Returns:

Type Description
list[Commit]

List of :class:Commit entries, newest first.

Source code in vcs/vcs.py
def log(self, n: int = 10) -> list[Commit]:
    """Return the last *n* commits.

    Args:
        n: Maximum number of commits.

    Returns:
        List of :class:`Commit` entries, newest first.
    """
    template = (
        'commit_id ++ "\\n" ++ '
        'commit_id.short() ++ "\\n" ++ '
        'if(author, author.name(), "") ++ "\\n" ++ '
        'if(author, author.timestamp(), "") ++ "\\n" ++ '
        'if(description, description.first_line(), "") ++ "\\n" ++ '
        '"\\x00"'
    )
    result = self._jj("log", "-n", str(n), "--no-graph", "-T", template)
    commits: list[Commit] = []
    for block in result.stdout.split("\x00"):
        block = block.strip()
        if not block:
            continue
        lines = block.splitlines()
        if len(lines) < 5:
            continue
        commits.append(
            Commit(
                hash=lines[0],
                short_hash=lines[1],
                author=lines[2],
                date=lines[3],
                message=lines[4],
            )
        )
    return commits

blame(path)

Return per-line annotation via jj file annotate.

Parameters:

Name Type Description Default
path str

File path relative to the repository root.

required

Returns:

Type Description
list[BlameLine]

List of :class:BlameLine entries.

Source code in vcs/vcs.py
def blame(self, path: str) -> list[BlameLine]:
    """Return per-line annotation via ``jj file annotate``.

    Args:
        path: File path relative to the repository root.

    Returns:
        List of :class:`BlameLine` entries.
    """
    result = self._jj("file", "annotate", path)
    blame_lines: list[BlameLine] = []
    line_no = 0
    for raw in result.stdout.splitlines():
        line_no += 1
        # jj file annotate format: <change_id> <committer> <date> <line>
        m = re.match(r"^(\S+)\s+(\S+)\s+(\S+)\s?(.*)", raw)
        if m:
            blame_lines.append(
                BlameLine(
                    commit=m.group(1),
                    author=m.group(2),
                    date=m.group(3),
                    line_no=line_no,
                    content=m.group(4),
                )
            )
    return blame_lines

current_branch()

Return the current bookmark or change ID.

Returns:

Type Description
str

First bookmark pointing to @, or the change ID.

Source code in vcs/vcs.py
def current_branch(self) -> str:
    """Return the current bookmark or change ID.

    Returns:
        First bookmark pointing to ``@``, or the change ID.
    """
    result = self._jj("bookmark", "list", "--pointing-to", "@")
    first = result.stdout.strip().splitlines()
    if first and first[0].strip():
        # Bookmark line format: "name: <change-id>"
        return first[0].split(":")[0].strip()
    # No bookmark — return change ID
    result = self._jj("log", "-r", "@", "--no-graph", "-T", "change_id.short()")
    return result.stdout.strip()

merge_file(base, ours, theirs)

Three-way merge using the sibling diff module.

Uses an injected merge_func if provided at construction time, otherwise falls back to sibling diff.merge3 auto-discovery.

Parameters:

Name Type Description Default
base str

Common-ancestor file content.

required
ours str

Content from the first branch.

required
theirs str

Content from the second branch.

required

Returns:

Type Description
str

Merged file content (may contain conflict markers).

Raises:

Type Description
NotImplementedError

If the sibling diff module is not available and no merge_func was injected, or if merge_func=None was passed explicitly.

Source code in vcs/vcs.py
def merge_file(self, base: str, ours: str, theirs: str) -> str:
    """Three-way merge using the sibling ``diff`` module.

    Uses an injected ``merge_func`` if provided at construction time,
    otherwise falls back to sibling ``diff.merge3`` auto-discovery.

    Args:
        base: Common-ancestor file content.
        ours: Content from the first branch.
        theirs: Content from the second branch.

    Returns:
        Merged file content (may contain conflict markers).

    Raises:
        NotImplementedError: If the sibling ``diff`` module is
            not available and no ``merge_func`` was injected,
            or if ``merge_func=None`` was passed explicitly.
    """
    merge3 = self._resolve_merge_func()
    result = merge3(base, ours, theirs)
    return result.content if hasattr(result, "content") else result

workspace_add(path, *, branch=None, rev=None)

Create a new workspace via jj workspace add.

The workspace name defaults to the basename of path (Jujutsu convention). If branch is given, a bookmark is created in the new workspace pointing to its working-copy change.

Parameters:

Name Type Description Default
path str

Directory for the new workspace.

required
branch str | None

If given, create a bookmark in the new workspace.

None
rev str | None

Starting revision.

None

Returns:

Type Description
str

Absolute path to the created workspace.

Source code in vcs/vcs.py
def workspace_add(
    self,
    path: str,
    *,
    branch: str | None = None,
    rev: str | None = None,
) -> str:
    """Create a new workspace via ``jj workspace add``.

    The workspace name defaults to the basename of *path* (Jujutsu
    convention).  If *branch* is given, a bookmark is created in the
    new workspace pointing to its working-copy change.

    Args:
        path: Directory for the new workspace.
        branch: If given, create a bookmark in the new workspace.
        rev: Starting revision.

    Returns:
        Absolute path to the created workspace.
    """
    abs_path = os.path.abspath(path)
    cmd: list[str] = ["workspace", "add", abs_path]
    if rev:
        cmd.extend(["-r", rev])
    self._jj(*cmd)
    if branch:
        # Create a bookmark in the new workspace context
        _run(
            [self._binary, "bookmark", "create", branch],
            cwd=abs_path,
            timeout=self._timeout,
            encoding=self._encoding,
        )
    return abs_path

workspace_remove(path, *, force=False)

Remove a workspace via jj workspace forget.

Derives the workspace name from the directory basename (Jujutsu default naming convention), then removes the directory.

Parameters:

Name Type Description Default
path str

Path to the workspace directory.

required
force bool

Unused — jj workspace forget always succeeds for registered workspaces.

False
Source code in vcs/vcs.py
def workspace_remove(self, path: str, *, force: bool = False) -> None:
    """Remove a workspace via ``jj workspace forget``.

    Derives the workspace name from the directory basename (Jujutsu
    default naming convention), then removes the directory.

    Args:
        path: Path to the workspace directory.
        force: Unused — ``jj workspace forget`` always succeeds for
            registered workspaces.
    """
    abs_path = os.path.abspath(path)
    ws_name = os.path.basename(abs_path)
    self._jj("workspace", "forget", ws_name)
    if os.path.isdir(abs_path):
        shutil.rmtree(abs_path)

workspace_list()

List all workspaces via jj workspace list.

Only the default workspace has a known path (repo_path); other workspace paths are not exposed by jj workspace list.

Returns:

Type Description
list[WorkspaceInfo]

List of :class:WorkspaceInfo entries.

Source code in vcs/vcs.py
def workspace_list(self) -> list[WorkspaceInfo]:
    """List all workspaces via ``jj workspace list``.

    Only the default workspace has a known path (``repo_path``);
    other workspace paths are not exposed by ``jj workspace list``.

    Returns:
        List of :class:`WorkspaceInfo` entries.
    """
    result = self._jj("workspace", "list")
    workspaces: list[WorkspaceInfo] = []
    for line in result.stdout.splitlines():
        # Format: "name: change_id_short commit_id_short ..."
        m = re.match(r"^(\S+):\s+(\S+)\s+(\S+)", line)
        if m:
            ws_name = m.group(1)
            commit_short = m.group(3)
            path = self._repo if ws_name == "default" else ""
            workspaces.append(
                WorkspaceInfo(
                    path=path,
                    head=commit_short,
                    branch=None,
                    is_main=ws_name == "default",
                )
            )
    return workspaces

branches()

List all bookmarks via jj bookmark list.

Returns:

Type Description
list[str]

List of bookmark name strings.

Source code in vcs/vcs.py
def branches(self) -> list[str]:
    """List all bookmarks via ``jj bookmark list``.

    Returns:
        List of bookmark name strings.
    """
    result = self._jj("bookmark", "list")
    names: list[str] = []
    for line in result.stdout.splitlines():
        # Format: "name: change_id commit_id ..."
        m = re.match(r"^(\S+):", line)
        if m:
            names.append(m.group(1))
    return names

create_branch(name, *, rev=None)

Create a bookmark via jj bookmark create.

Parameters:

Name Type Description Default
name str

Bookmark name.

required
rev str | None

Target revision (defaults to @).

None
Source code in vcs/vcs.py
def create_branch(self, name: str, *, rev: str | None = None) -> None:
    """Create a bookmark via ``jj bookmark create``.

    Args:
        name: Bookmark name.
        rev: Target revision (defaults to ``@``).
    """
    cmd: list[str] = ["bookmark", "create", name]
    if rev:
        cmd.extend(["-r", rev])
    self._jj(*cmd)

switch(target)

Switch working copy via jj new.

Creates a new change on top of target, analogous to how git switch positions the working copy for new commits.

Parameters:

Name Type Description Default
target str

Bookmark name or revision identifier.

required
Source code in vcs/vcs.py
def switch(self, target: str) -> None:
    """Switch working copy via ``jj new``.

    Creates a new change on top of *target*, analogous to how
    ``git switch`` positions the working copy for new commits.

    Args:
        target: Bookmark name or revision identifier.
    """
    self._jj("new", target)

commit(message, *, paths=None)

Finalize the current change via jj commit.

The paths argument is ignored — Jujutsu auto-tracks all working-copy changes.

Parameters:

Name Type Description Default
message str

Change description.

required
paths list[str] | None

Ignored.

None

Returns:

Type Description
str

Full commit hash of the finalized change.

Source code in vcs/vcs.py
def commit(self, message: str, *, paths: list[str] | None = None) -> str:
    """Finalize the current change via ``jj commit``.

    The *paths* argument is ignored — Jujutsu auto-tracks all
    working-copy changes.

    Args:
        message: Change description.
        paths: Ignored.

    Returns:
        Full commit hash of the finalized change.
    """
    self._jj("commit", "-m", message)
    # The committed change is now @- (parent of current)
    result = self._jj("log", "-r", "@-", "--no-graph", "-T", "commit_id")
    return result.stdout.strip()

rev_parse(rev)

Resolve a revision string to a full commit hash.

Parameters:

Name Type Description Default
rev str

Revision string (bookmark, change ID, etc.).

required

Returns:

Type Description
str

Full commit hash.

Source code in vcs/vcs.py
def rev_parse(self, rev: str) -> str:
    """Resolve a revision string to a full commit hash.

    Args:
        rev: Revision string (bookmark, change ID, etc.).

    Returns:
        Full commit hash.
    """
    result = self._jj("log", "-r", rev, "--no-graph", "-T", "commit_id")
    return result.stdout.strip()

detect(path='.', *, merge_func=_UNSET)

Auto-detect the VCS backend for the given path.

Walks upward from path to the filesystem root looking for .git/, .hg/, or .jj/ directories. Returns the first backend whose marker directory is found and whose binary is available on the system, or None if no VCS is detected.

Parameters:

Name Type Description Default
path str

Starting directory (defaults to ".").

'.'
merge_func Callable[[str, str, str], Any] | None | _Unset

Forwarded to Mercurial / Jujutsu constructors. See their docstrings for semantics. Ignored for Git (which uses git merge-file directly).

_UNSET

Returns:

Type Description
VCSBackend | None

An instantiated backend, or None.

Source code in vcs/vcs.py
def detect(
    path: str = ".",
    *,
    merge_func: Callable[[str, str, str], Any] | None | _Unset = _UNSET,
) -> VCSBackend | None:
    """Auto-detect the VCS backend for the given path.

    Walks upward from *path* to the filesystem root looking for
    ``.git/``, ``.hg/``, or ``.jj/`` directories.  Returns the first
    backend whose marker directory is found **and** whose binary is
    available on the system, or ``None`` if no VCS is detected.

    Args:
        path: Starting directory (defaults to ``"."``).
        merge_func: Forwarded to ``Mercurial`` / ``Jujutsu`` constructors.
            See their docstrings for semantics.  Ignored for ``Git``
            (which uses ``git merge-file`` directly).

    Returns:
        An instantiated backend, or ``None``.
    """
    current = os.path.abspath(path)
    while True:
        for marker, binary_name, cls in _BACKENDS:
            marker_path = os.path.join(current, marker)
            if os.path.isdir(marker_path) or os.path.isfile(marker_path):
                try:
                    _find_binary(binary_name)
                except BinaryNotFoundError:
                    continue
                if cls is Git:
                    return cls(current)
                return cls(current, merge_func=merge_func)
        parent = os.path.dirname(current)
        if parent == current:
            break
        current = parent
    return None