Skip to content

VCS

Git/Mercurial/Jujutsu CLI wrapper -- zero dependencies, stdlib only, Python 3.10+.

Replaces: GitPython, pygit2 (high-level)

Overview

The VCS module provides a uniform Python interface to version-control systems by shelling out to their CLI binaries. Each backend implements the VCSBackend protocol, enabling VCS-agnostic tooling. Cross-platform binary discovery handles Linux, macOS, and Windows.

File Description Dependencies
vcs.py VCS CLI wrapper None (stdlib only)

The module supports three VCS backends:

Backend Binary Coverage
Git git Full (diff, status, log, blame, apply, merge-file, branch, workspace, commit)
Mercurial hg Inspection only (diff, status, log, blame, branch)
Jujutsu jj Full (diff, status, log, blame, merge-file, branch, workspace, commit)

How to Use in Your Project

Just copy the single .py file into your project:

cp vcs/vcs.py your_project/

Then import directly:

from vcs import detect, Git

API Reference

detect(path)

Auto-detect the VCS backend for a given directory.

def detect(path: str = ".") -> VCSBackend | None

Walks upward from path checking for .git/, .hg/, or .jj/ directories. Returns the corresponding backend instance if the binary is available, or None if no repository is found.

Example:

from vcs import detect

repo = detect(".")
if repo is not None:
    print(repo.name)           # "git", "hg", or "jj"
    print(repo.current_branch())

VCSBackend Protocol

All backends implement this protocol:

class VCSBackend(Protocol):
    name: str
    # -- inspection --
    def is_repo(self, path: str) -> bool: ...
    def diff(self, *paths: str, staged: bool = False) -> str: ...
    def diff_files(self, path_a: str, path_b: str) -> str: ...
    def apply(self, patch: str) -> None: ...
    def status(self) -> list[FileStatus]: ...
    def log(self, n: int = 10) -> list[Commit]: ...
    def blame(self, path: str) -> list[BlameLine]: ...
    def current_branch(self) -> str: ...
    def merge_file(self, base: str, ours: str, theirs: str) -> str: ...
    # -- workspace lifecycle --
    def workspace_add(self, path: str, *, branch: str | None = None, rev: str | None = None) -> str: ...
    def workspace_remove(self, path: str, *, force: bool = False) -> None: ...
    def workspace_list(self) -> list[WorkspaceInfo]: ...
    # -- branch / commit --
    def branches(self) -> list[str]: ...
    def create_branch(self, name: str, *, rev: str | None = None) -> None: ...
    def switch(self, target: str) -> None: ...
    def commit(self, message: str, *, paths: list[str] | None = None) -> str: ...
    def rev_parse(self, rev: str) -> str: ...

Git Backend

The most complete backend. Created by passing a repository path:

from vcs import Git

g = Git("/path/to/repo")

status()

Returns a list of FileStatus objects for the working tree.

statuses = g.status()
for s in statuses:
    print(s.status, s.path)  # e.g. "M file.txt", "? new.txt"

Status codes: M (modified), A (added), D (deleted), R (renamed), ? (untracked).

diff(*paths, staged=False)

Returns unified diff text for working tree changes.

d = g.diff()                    # All unstaged changes
d = g.diff(staged=True)         # Staged changes only
d = g.diff("src/main.py")      # Specific file

diff_files(path_a, path_b)

Diff two arbitrary files (not necessarily tracked).

d = g.diff_files("/tmp/old.txt", "/tmp/new.txt")

log(n=10)

Returns recent commits as Commit objects.

commits = g.log(n=5)
for c in commits:
    print(c.short_hash, c.author, c.message)

blame(path)

Returns per-line blame information as BlameLine objects.

lines = g.blame("README.md")
for bl in lines:
    print(f"{bl.commit[:8]} {bl.author:>15}  {bl.content}", end="")

apply(patch)

Apply a unified diff patch to the working tree.

g.apply(diff_text)

current_branch()

Returns the current branch name, or the short commit hash for detached HEAD.

branch = g.current_branch()  # "main", "feature/xyz", etc.

merge_file(base, ours, theirs)

Three-way merge of text content using git merge-file.

result = g.merge_file(base_text, our_text, their_text)

Workspace Lifecycle

These methods manage isolated workspaces (Git worktrees / Jujutsu workspaces). Mercurial raises NotImplementedError for all workspace and branch operations.

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

Create a new isolated workspace at the given path.

ws_path = g.workspace_add("/tmp/feature-ws", branch="feature/new")
  • Git: runs git worktree add [-b branch] <path> [rev]
  • Jujutsu: runs jj workspace add <path> [-r rev], optionally creates a bookmark

workspace_remove(path, *, force=False)

Remove a workspace and clean up its directory.

g.workspace_remove("/tmp/feature-ws")
g.workspace_remove("/tmp/dirty-ws", force=True)  # force even with uncommitted changes

workspace_list()

List all workspaces as WorkspaceInfo objects.

for ws in g.workspace_list():
    print(ws.path, ws.branch, ws.is_main)

Branch Operations

branches()

List all branch names (Git) or bookmarks (Jujutsu).

branch_names = g.branches()  # ["main", "feature/xyz", ...]

create_branch(name, *, rev=None)

Create a new branch or bookmark.

g.create_branch("feature/new")
g.create_branch("hotfix", rev="HEAD~3")

switch(target)

Switch to a branch or revision.

g.switch("feature/new")      # switch to branch
g.switch("abc1234")           # detach HEAD at revision (Git)
  • Git: uses git switch, falls back to git switch --detach for non-branch targets
  • Jujutsu: uses jj new to create a new change on top of the target

Commit Operations

commit(message, *, paths=None)

Create a commit and return its full hash.

sha = g.commit("fix: resolve edge case", paths=["src/fix.py"])
  • Git: stages paths (if given) then commits; otherwise commits what is already staged
  • Jujutsu: finalizes the current change; paths is ignored (jj auto-tracks all changes)

rev_parse(rev)

Resolve a revision string to a full commit hash.

full_hash = g.rev_parse("HEAD")
full_hash = g.rev_parse("main")

Data Structures

FileStatus

Frozen dataclass representing a file's status.

  • path: str -- Relative path.
  • status: str -- Single-character code ('M', 'A', 'D', 'R', '?', '!').
  • original_path: str | None -- For renames, the original path.

Commit

Frozen dataclass for commit metadata.

  • hash: str -- Full commit hash.
  • short_hash: str -- Abbreviated hash.
  • author: str -- Author name.
  • date: str -- ISO 8601 date string.
  • message: str -- Commit message subject.

BlameLine

Frozen dataclass for per-line blame information.

  • commit: str -- Commit hash.
  • author: str -- Author name.
  • date: str -- Commit date.
  • line_no: int -- 1-based line number.
  • content: str -- Line content.

WorkspaceInfo

Frozen dataclass for workspace metadata.

  • 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.
  • is_main: bool -- Whether this is the main/default workspace.

Exceptions

Exception When Raised
VCSError Base class for all VCS errors.
BinaryNotFoundError VCS binary not found on the system (with binary_name).
CommandError VCS command exited with unexpected return code (with command, returncode, stderr).
NotARepoError Path is not inside a repository (with path).

Cross-Platform Binary Discovery

The module uses a three-tier strategy to locate VCS binaries:

  1. Environment variable override: ZERODEP_GIT_PATH, ZERODEP_HG_PATH, ZERODEP_JJ_PATH
  2. shutil.which(): Standard PATH lookup (works on all platforms)
  3. Windows fallback: Checks common install directories (e.g. C:\Program Files\Git\bin\git.exe)

macOS Homebrew paths (/opt/homebrew/bin, /usr/local/bin) are included in the search.

Cross-Module Integration

The Mercurial and Jujutsu backends optionally use the sibling diff/diff.py module's merge3() function for merge_file(). If the diff module is not available, merge_file() raises NotImplementedError for those backends.

Notes and Caveats

Subprocess-Based

All VCS operations shell out to the CLI binary. This means the corresponding VCS tool must be installed on the system. The module does not embed or bundle any VCS implementation.

Windows Support

On Windows, subprocess calls use CREATE_NO_WINDOW to prevent console window flashing. Binary discovery includes common Windows installation paths.

  • Python version: Requires Python 3.10+.
  • Mercurial is inspection-only: Workspace, branch, commit, and rev-parse operations raise NotImplementedError on the Mercurial backend due to fundamental semantic differences. Git and Jujutsu have full lifecycle support.

Benchmark

No benchmark is provided for this module. All operations are subprocess-based, so performance is dominated by process startup and CLI execution time rather than Python wrapper code -- see VCS Benchmark for details.