Luke's Web Site

Finding Uncommitted Changes in Your Git Repos

Like most people, I’ve got a ton of Git repos sitting in a folder at ~/src. Turns out I have terrible habits and I tend to leave a lot of changes uncommitted, just sitting in my local repo. I want these repos to have remotes that are up to date, and that means I need to commit the changes, but checking each one by hand is way too tedious. I’d like an automation that tells me which repos have dirty working copies.

Here’s my first pass at solving this problem1. It’s a short Python program that searches the working directory recursively for Git repos, and when it finds them it checks if they have modified files that haven’t been committed (using git status -s). If they do, it prints the directory location and what files are still ‘dirty’.

Here’s the source code in full, with notes below:

#!/usr/bin/env python3

import subprocess
from pathlib import Path


def git_dirs(dir: Path):
    for p in sorted([p for p in dir.iterdir() if p.is_dir()]):
        if (p / ".git").is_dir():
            yield p
        else:
            yield from git_dirs(p)


def git_status(repo: Path) -> list[str]:
    return (
        subprocess.run(
            ["git", "status", "-s"],
            cwd=repo,
            capture_output=True,
        )
        .stdout.decode()
        .split("\n")
    )


def main():
    num_dirty_repos = 0
    for p in git_dirs(Path.cwd()):
        status = git_status(p)
        if not status or status == [""]:
            continue

        num_dirty_repos += 1
        print(f"\033[1m{p.relative_to(Path.cwd())}\033[0m")
        for line in status:
            print(line)

    print(f"{num_dirty_repos} repos have uncommitted changes")


if __name__ == "__main__":
    main()

The git_dirs function finds Git repos in the current directory, or its descendants, and yields them. Python’s yield from makes this kind of recursive generator super easy to write. Note that I used Path.iterdir() with manual recursion instead of .glob() because this way I can skip scanning inside Git repos, which saves a lot of time.

git_status is just a single expression that runs git status -s (the -s stands for “short”) in the provided directory and reports the results. I’m not doing any kind of parallelism even though that might speed things up a lot — I’ll worry about speed later, this is just a rough cut. I chose to return a list of lines instead of one big string because I’m thinking about adding more features to this like displaying the number of changed files in the future, and that might come in handy.

With these tools, it’s pretty simple to put the final script together in main. Iterate over the Git repos, skip the ones with nothing reported from git_status, and print the results with a nice bold header per repo.

I’ve symlinked the program to ~/bin/dirty so I can summon it at any time by running dirty at the command line. Here’s what it looks like when I run it in ~/src:

> dirty | head -n 25
archive/hackathon-2023
 M .DS_Store
 M backend/__pycache__/application.cpython-311.pyc
?? backend/users/sdflskdjfl.json

archive/rpi-controller
 M server/main.py
 M server/run-server-command.sh
?? package-lock.json

c-to-zig
 M build.zig
 D compute.c
 A compute.zig

comp/3030
 M a1/q1b.typ
 M a4/q1.typ
 M a4/q5.typ
 M definitions.typ
?? a2/appeal.typ
?? a4/q6.typ

comp/3060
 M unitD/D1/D1a.js
> dirty | wc -l
     264

Oof. I should really get started cleaning this up.

After the first draft of this post, I added a simple counter so that I can print the number of dirty repos at the end (the code above is the new version with the counter). This counter serves the important function of making me feel bad, and since then I’ve reduced the number of dirty repos from 35 to 24. That’s progress!

In the future I’d like to add more summary statistics, like the number of clean vs. dirty repos or how many changes are hanging around in each repo, but this works pretty well for now. Rest assured that if I do add features, I’ll write them up and post them here. I wonder if there’s a better way to get information out of Git than just calling subprocess.run

1: Well, the true first pass was this: for dir in (ls); echo $dir; pushd $dir; git status; popd; end