---
name: git-without-xcode-macos
description: Use dulwich (pure-Python Git) to init, add, commit, and manage git repos on macOS when Xcode Command Line Tools are not installed and the system git triggers the xcode-select dialog.
---

# Git Without Xcode on macOS (using dulwich)

When `/usr/bin/git` on macOS triggers the "xcode-select: note: no developer tools were found" dialog instead of running, you can use **dulwich** — a pure-Python Git implementation that works without any Xcode/CLT tools.

## When to use

- macOS without Xcode Command Line Tools installed
- User can't or won't install Xcode (full Xcode is 10+ GB)
- Need basic Git operations: init, add, commit, status, log
- Working offline / no push needed (dulwich can push but it's limited)

## Installation

```bash
pip3 install dulwich
```

## Usage

### Init + First Commit

**Important API note:** dulwich's porcelain methods can take either a string path OR a `Repo` object. Using `Repo` objects gives more control.

```python
from dulwich import porcelain
from dulwich.repo import Repo
import os

basedir = "/path/to/repo"
os.makedirs(basedir, exist_ok=True)

# Option A: String path (simple)
porcelain.init(basedir)
porcelain.add(basedir, paths=["."])
porcelain.commit(basedir, message="Initial commit",
                 committer=b"Name <email@test.com>")

# Option B: Repo object (more control)
repo = porcelain.open_repo(basedir)  # open after init
# Or use the repo object returned by init() directly:
# repo = porcelain.init(basedir)  # returns Repo in most versions
```

**Gotcha:** `porcelain.init()` returns different things across dulwich versions. Safest: call `porcelain.init()` then `porcelain.open_repo()`.

**Important:** `porcelain.commit` requires `committer` parameter (bytes) in newer dulwich versions, otherwise it may fail.

### Check Status + Log

```python
from dulwich import porcelain
import os

repo = porcelain.open_repo("/path/to/repo")

# HEAD commit hash
print(repo.head().decode()[:12])

# Status
status = porcelain.status("/path/to/repo")
print(f"Staged: {len(status.staged)}")
print(f"Modified: {len(status.unstaged)}")
print(f"Untracked: {len(status.untracked)}")

# Log — dulwich porcelain.log() can be finicky, use repo directly:
from dulwich.objects import Commit
for entry in repo.get_refs():
    if entry.startswith(b"refs/heads/"):
        print(entry)
```

### Adding .gitignore

Create BEFORE the first `porcelain.add()` call so untracked files are excluded:

```bash
# Write .gitignore
cat << 'EOF' > .gitignore
# Python
__pycache__/
*.py[cod]
.venv/
venv/

# Environment
.env
.env.local

# OS
.DS_Store
Thumbs.db

# Node
node_modules/
.next/

# IDE
.vscode/
.idea/
EOF
```

## Limitations

- No `git diff`, `git branch` (basic branch is possible but clunky)
- No remote operations (push/pull) in simple mode — though `porcelain.push` exists
- Log iteration can throw `TypeError: 'NoneType' object is not iterable` when there's only one commit — use `repo.get_refs()` instead
- Best for: offline tracking, local version control, MVP repos

## Full Example (init + add + commit)

```bash
cd ~/my-project
python3 -c "
from dulwich import porcelain
porcelain.init('.')
porcelain.add('.', paths=['.'])
porcelain.commit('.', message='Initial commit')
print('✅ Git repo initialized without Xcode')
"
```

## If You Need Full Git Later

When the user eventually installs CLT or Xcode, dulwich repos are standard `.git` directories — fully compatible with real `git`.
