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>
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:
ghis 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.gitdirectory.- The two surfaces don't overlap. Local commits, branches, diffs, staging, stash = GitPython. GitHub PR lifecycle = gh subprocess.
- This pattern is standard in Git tooling (e.g. hub, lab, glab all work this way).
- 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.namegit rev-parse HEAD— GitPython:repo.head.commit.hexshagit 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
ghcommands (GitHub API, no GitPython equivalent) git symbolic-ref -q HEAD(GitPython equivalent isrepo.head.is_detached)