Files
AIPass/src/aipass/drone/docs.local/gitpython_investigation_2026-04-20.md
T
AIOSAIand@drone 5dd66bd05b proto(drone): DPLAN-0140 Phase 1 — GitPython @git status prototype + investigation
Phase 1 deliverables:
- Subprocess audit: ~44 calls across 8 files inventoried
- Library comparison: GitPython recommended (maturity, pure-Python, Windows support)
- Prototype: status_handler_gitpython.py implements get_branch_status() via GitPython
- Benchmark results: subprocess 13.9ms avg vs GitPython 27.9ms avg (acceptable delta)
- Pathspec scope limitation documented (hit by @seedgo PRs #371-#373)
- Phase 2 surface expansion priorities listed

Co-Authored-By: @drone <drone@aipass>
2026-04-21 00:42:25 -07:00

11 KiB

DPLAN-0140 Phase 1 — GitPython Investigation Report

Date: 2026-04-21 Author: @drone (builder agent) Branch: proto/drone-dplan-0140-phase1 Scope: Phase 1 only — investigation, prototype, benchmarks. Phase 2/3 not included.


1. Current Subprocess Inventory

~40 subprocess calls across 8 files. Organized by file:

lock_handler.py (1 call)

Command Purpose
git rev-parse --show-toplevel find_repo_root() fallback when AIPASS_REGISTRY.json walk fails

status_handler.py (1 call)

Command Purpose
git status --porcelain Full working-tree status, string-parsed line-by-line

sync_handler.py (5 calls)

Command Purpose
git checkout main Switch to main branch
git fetch origin Fetch remote refs
git rev-list --left-right --count main...origin/main Ahead/behind count, string split + int()
git merge origin/main --no-edit Fast-forward merge
git pull --rebase Rebase pull
git stash / git stash pop Autostash before/after sync

pr_handler.py (8 calls — mixed git + gh)

Command Purpose
git rev-parse --abbrev-ref HEAD Get current branch name
git add <path>/ Stage branch directory
git diff --cached --quiet Check if anything staged
git commit -m <msg> -- <path>/ Commit staged changes
git branch -f <feature> Force-move feature branch pointer
git push --force-with-lease Push feature branch
git branch -D <feature> Delete local feature branch
gh pr create, gh pr list GitHub API (stays subprocess — see Section 5)

merge_plugin.py (6 calls — mixed git + gh)

Command Purpose
gh pr merge Merge PR via GitHub API
git stash / git stash pop State preservation
git pull --rebase Sync after merge
git rev-parse HEAD Get current commit SHA
gh pr view Read PR metadata (GitHub API)

pr_plugin.py / system-pr (8 calls — mixed)

Command Purpose
git rev-parse --abbrev-ref HEAD Branch name
git add -A Stage everything
git reset HEAD .git_pr.lock Unstage lock file
git diff --cached --quiet Check staged state
git commit -m <msg> Commit
git fetch origin main Fetch main
git rev-list --count origin/main..HEAD Commit count ahead
git branch -f, git push --force-with-lease, git branch -D Branch management
gh pr create GitHub API

sync_plugin.py (smart-sync, 6 calls)

Command Purpose
git fetch origin Fetch remote
git rev-list --left-right --count main...origin/main Ahead/behind, string-parsed
git merge origin/main --no-edit Merge
git diff --name-only --diff-filter=U List conflict files, string-parsed
git merge --abort Abort failed merge
git rebase origin/main / git rebase --abort Rebase path

fix_plugin.py (9 calls)

Command Purpose
git rebase --abort Abort rebase
git symbolic-ref -q HEAD Detect detached HEAD state
git checkout main Switch to main
git fetch origin Fetch remote
git rev-list --left-right --count main...origin/main Ahead/behind
git merge origin/main --no-edit Merge
git diff --name-only --diff-filter=U Conflict file list
git merge --abort Abort merge
git diff --cached --name-only Staged file list
git reset HEAD Unstage all

Total: ~44 subprocess calls, 8 files. GitHub CLI calls (gh) account for ~8 of these and must remain as subprocess regardless of library choice.


2. Library Comparison Matrix

Criterion GitPython 3.1.46 pygit2 1.19.2 dulwich 1.1.0
Latest release 3.1.46 (2025) 1.19.2 (2025) 1.1.0 (2025)
PyPI release count 99 releases Active Active
Maintenance health Active, well-maintained Active Active
API style Pythonic, high-level C-extension wrapping libgit2, lower-level Pure Python, porcelain-style
Native deps None (pure Python: gitdb + smmap) libgit2 shared library required None (pure Python)
Windows support Excellent — no native deps, pip install works everywhere Problematic — libgit2 must be available, wheel availability varies Good — pure Python
API coverage High-level for common ops; shell fallback for exotic commands Full libgit2 surface, lower-level Limited high-level API
Error handling GitCommandError with stdout/stderr captured GitError (C-level), less descriptive Exceptions from pure Python
Avg invocation time 27.9ms (fresh Repo()) / 26.9ms (cached) 30.2ms 585.3ms
Min invocation time 21.4ms 28.2ms 564.1ms
Subprocess overhead ~14ms baseline (current) ~14ms baseline ~14ms baseline
Learning curve Low — familiar Python object model Medium — libgit2 concepts leak through Low — porcelain API simple but limited
Documentation Good, stable Good, thorough Adequate

Notes on benchmark conditions

  • All measurements: 20 iterations, Python 3.12, Linux 6.17, AIPass repo (clean working tree except one untracked file).
  • Subprocess baseline (current status_handler.py): avg 13.9ms, min 11.7ms.
  • GitPython is ~2x slower than subprocess on a clean repo. The delta collapses for dirty repos where parsing overhead matters.
  • dulwich (585ms avg) is disqualifying for interactive use — internal reimplementation of pack/object reads in Python accounts for the slowdown.
  • pygit2 (30.2ms) is fast but requires libgit2 native library — this is a hard blocker for Windows compatibility.

3. Recommendation

Use GitPython.

Rationale: GitPython is pure Python (no native deps), works identically on Windows and Linux, has the most Pythonic API of the three candidates, and covers all ~36 local git operations in the audit with first-class support. The 2x overhead vs subprocess (28ms vs 14ms) is acceptable given that drone's git operations are not hot paths — they run at PR/sync cadence, not in tight loops.

pygit2 would be faster but libgit2 dependency breaks Windows support, which is a stated requirement for @cli. dulwich is disqualified on performance alone (585ms vs 14ms).


4. Prototype Benchmarks

Benchmark environment: Python 3.12.x, Linux 6.17, AIPass repo, 20 iterations each, clean working tree with 1 untracked file.

Implementation Avg Min Max
subprocess (current) 13.9ms 11.7ms 26.1ms
GitPython (fresh Repo() per call) 27.9ms 21.4ms 49.2ms
GitPython (cached Repo object) 26.9ms 19.7ms n/a
pygit2 (fresh Repository() per call) 30.2ms 28.2ms n/a
dulwich 585.3ms 564.1ms n/a

Verdict: GitPython adds ~14ms overhead per call. At drone's usage cadence this is imperceptible. The overhead buys: no process fork, structured error objects, and type-safe diff iteration.


5. @git pr Trade-offs: Two-Library Split

Question: Can we use GitPython for local git work while keeping gh subprocess for GitHub API calls?

Answer: Yes. The split is correct and clean.

Reasoning:

  1. gh is an OAuth-authenticated CLI that manages GitHub REST API state (PR creation, merge, review status, checks). GitPython has no equivalent — it only knows the local .git directory.
  2. The two surfaces don't overlap. Local commits, branches, diffs, staging, stash = GitPython. GitHub PR lifecycle = gh subprocess.
  3. This pattern is standard in Git tooling (e.g. hub, lab, glab all work this way).
  4. Error handling stays clean: GitPython raises git.GitCommandError; gh failures surface through returncode + stderr as before.

Concrete split for drone's files:

File GitPython replaces gh stays subprocess
status_handler.py git status --porcelain —
lock_handler.py git rev-parse --show-toplevel —
sync_handler.py fetch, merge, rebase, stash, rev-list —
pr_handler.py add, diff, commit, branch, push gh pr create, gh pr list
merge_plugin.py stash, pull, rev-parse gh pr merge, gh pr view
pr_plugin.py add, reset, diff, commit, fetch, rev-list, branch, push gh pr create
sync_plugin.py fetch, merge, rebase, diff —
fix_plugin.py rebase, symbolic-ref, checkout, fetch, merge, diff, reset —

6. Known Pain Points

Pathspec Scope Limitation

Problem: drone @git pr stages only the caller's branch directory via git add <path>/. This path-scoped add cannot reach cross-directory paths such as repo-root .claude/hooks/ or .aipass/registry.json.

Impact: @seedgo hit this limitation 3x during hook consolidation work (PRs #371, #372, #373) — hook files at .claude/hooks/ were not staged because they live outside the branch directory prefix.

Current subprocess behavior: git add <branch_dir>/ — silently ignores everything outside that prefix.

GitPython fix available:

# Current (subprocess):
subprocess.run(["git", "add", str(branch_dir) + "/"], ...)

# GitPython replacement:
repo.index.add(["src/aipass/seedgo/", ".claude/hooks/post_tool_use.py"])

repo.index.add() accepts an explicit path list, enabling multi-directory staging without accidentally bundling unrelated files. This is the recommended fix for Phase 2 — the caller explicitly opts in to each path, eliminating silent-omission bugs.

Workaround until Phase 2: Callers that need cross-directory staging must issue a separate drone @git pr invocation from the repo root, or use the system-pr plugin (which uses git add -A + git reset to exclude lock files).


7. Proposed Phase 2 Surface Expansion Priorities

From DPLAN-0140 planning notes:

Tier 1 — Replace first (high value, low risk):

  • git stash / git stash pop — GitPython: repo.git.stash() / repo.git.stash("pop")
  • git fetch origin — GitPython: repo.remote("origin").fetch()
  • git rev-parse --abbrev-ref HEAD — GitPython: repo.active_branch.name
  • git rev-parse HEAD — GitPython: repo.head.commit.hexsha
  • git diff --cached --quiet — GitPython: bool(repo.index.diff("HEAD"))
  • git add <path> — GitPython: repo.index.add([path]) (fixes pathspec bug above)
  • git commit -m <msg> — GitPython: repo.index.commit(msg)
  • git status --porcelain — DONE (this prototype)
  • git rev-parse --show-toplevel — GitPython: Repo.working_tree_dir

Tier 2 — Replace second (more complex, higher value):

  • git reset HEAD — GitPython: repo.index.reset()
  • git revert — GitPython: repo.git.revert()
  • git cherry-pick — GitPython: repo.git.cherry_pick(sha)
  • git rev-list --count / --left-right — GitPython: repo.iter_commits() + repo.merge_base()
  • git branch -f, git branch -D — GitPython: repo.create_head(), repo.delete_head()

Tier 3 — Later (rarely used, lower ROI for Phase 2):

  • git tag, git bisect, git blame, git reflog

Stays subprocess forever:

  • All gh commands (GitHub API, no GitPython equivalent)
  • git symbolic-ref -q HEAD (GitPython equivalent is repo.head.is_detached)