Skip to main content

Command Palette

Search for a command to run...

Running Git Commands from Python

Updated
3 min readView as Markdown
B
I build CLI tools and developer productivity scripts in Python and Rust.

Once scaffold writes all the project files, it needs to turn the directory into an actual git repository - run git init, stage everything, and make the first commit. I went with running the git binary as a subprocess rather than using a library like GitPython. It meant only less dependency, and I already knew the exact git command I needed. No reason to learn a new library's API for something the command line already handles well.

The subprocess call

Here's the helper that wraps every git command scaffoldr runs:

def _git(args: list[str], cwd: Path) -> None:
    result = subprocess.run(
        ["git", *args],
        cwd=cwd,
        capture_output=True,
        text=True,
    )
    if result.returncode != 0:
        typer.echo(f"git error: {result.stderr.strip()}", err=True)
        raise typer.Exit(code=1)

subprocess.run executes a command as if you'd typed it in a terminal. The first argument is a list - ["git", *args] - where *args unpacks whatever command-specific arguments get passed in. cwd tells it which directory to run the command in.

Why cwd matters

subprocess.run runs the command starting from Python's own working directory by default - wherever you launched scaffoldr from, not the directory you're turning into a repo. Passing cwd tells it to run the command as if you'd cd'd into that directory first. Without this, git init would initialize a repo in the wrong place.

Capturing output intead of letting it print

capture_output=True redirects the subprocess's stdout and stderr into the result object instead of letting them print directly to the terminal. text=True decodes the output as a string instead of raw bytes, so you can call .strip() directly on result.stderr.

This matters because raw subprocess output would be messy - git's own stdout/stderr mixed in with whatever else scaffoldr is printing at the same time. Capturing it means the error message stays clean and readable: we can print just the relevant part.

Checking for failure

result.returncode is the exit code the git process finished with. 0 means success - this is a convention nearly every command-line program follows, not something specific to git. Any non-zero value means something went wrong.

If returncode != 0, scaffoldr prints the captured stderr and exits with typer.Exit(code=1). This propagates the failure up - if git init fails, scaffold doesn't continue trying to git add and git commit on a repo that was never created.

Running the sequence

Back in scaffold, the actual git setup is three calls:

_git(["init"], cwd=root)
_git(["add", "."], cwd=root)
_git(["commit", "-m", "chore: initial scaffold"], cwd=root)

Initialize the repo, stage every file, commit. Each calls reuses the same _git helper, so error handling for all three is identical - no repeated subprocess.run boilerplate for each command.

What's next

With local scaffolding done - files written, git repo initialized - the next step was making scaffoldr new actually talk to GitHub. That means authenticating, creating a repo through the API, and connecting the local repo to it. That's the next post.

The code is on GitHub