DEV Community

SEN LLC
SEN LLC

Posted on

Building a .gitignore Generator CLI in Python — 51 Templates, Zero Dependencies

Building a .gitignore Generator CLI in Python — 51 Templates, Zero Dependencies

A Python CLI that generates .gitignore files from 51 built-in templates, merges them with automatic deduplication, and writes the result — all offline, using only the standard library.

Every new project starts the same way: you create a directory, run git init, and then spend five minutes on gitignore.io or GitHub's template picker trying to assemble a .gitignore that covers your language, your editor, and your operating system. Three clicks, some scrolling, maybe a copy-paste from a previous project. It works, but it is friction you repeat hundreds of times across your career.

I built gitignore-gen to eliminate that friction. It ships 51 templates covering every major language, editor, OS, game engine, and build tool. You type gitignore-gen python node macos, and you get a clean, deduplicated .gitignore on stdout or written directly to a file. No network requests. No API keys. No dependencies beyond the Python standard library.

📦 GitHub: https://github.com/sen-ltd/gitignore-gen

Screenshot

What it does

The tool operates in four modes, each designed for a different workflow:

# Combine templates and print to stdout
$ gitignore-gen python node macos

# Write directly to a .gitignore file
$ gitignore-gen python node --output .gitignore

# List all 51 available templates
$ gitignore-gen --list

# Search templates by keyword
$ gitignore-gen --search ide
Enter fullscreen mode Exit fullscreen mode

The most common use case is combining two or three templates. A typical web project needs a language template (Python, Node, etc.), an editor template (VSCode, JetBrains), and an OS template (macOS, Windows). Instead of manually merging three files and removing duplicate entries, gitignore-gen handles it in one command.

Alias Support

Nobody wants to type objective-c or sublimetext every time. The tool supports 40+ aliases for convenience:

# These are equivalent
$ gitignore-gen py js mac
$ gitignore-gen python node macos

# IDE aliases
$ gitignore-gen idea    # → jetbrains
$ gitignore-gen code    # → vscode
$ gitignore-gen nvim    # → vim

# Language aliases
$ gitignore-gen rs      # → rust
$ gitignore-gen ts      # → typescript
$ gitignore-gen rb      # → ruby
Enter fullscreen mode Exit fullscreen mode

The alias system is intentionally generous. If you can think of a reasonable short name for a template, it probably works.

Architecture: Three Modules, Clear Boundaries

The project follows a strict separation of concerns across three modules. Each has a single responsibility and a well-defined interface.

The Template Database (src/templates.py)

The heart of the tool is a Python dictionary mapping template names to lists of gitignore patterns:

TEMPLATES: dict[str, list[str]] = {
    "python": [
        "# Python",
        "__pycache__/",
        "*.py[cod]",
        "*$py.class",
        "*.so",
        "*.egg",
        "*.egg-info/",
        "dist/",
        "build/",
        # ... 30+ more patterns
    ],
    "node": [
        "# Node.js",
        "node_modules/",
        "npm-debug.log*",
        # ...
    ],
    # ... 49 more templates
}
Enter fullscreen mode Exit fullscreen mode

Every template starts with a comment header (# Python, # Node.js, etc.) that serves as a section separator in the merged output. This is a deliberate design choice — when you open a generated .gitignore file six months later, you can immediately see which templates contributed which patterns.

I chose to embed templates as a Python dict rather than loading from external files or fetching from the GitHub gitignore repository. This makes the tool completely offline-capable and eliminates an entire category of failure modes: no network timeouts, no API rate limits, no stale cache directories. The trade-off is that template updates require a code change, but gitignore patterns change infrequently enough that this is acceptable.

The module also provides three utility functions:

def resolve(name: str) -> str | None:
    """Resolve a name or alias to a canonical template key."""

def list_all() -> list[str]:
    """Return sorted list of all available template names."""

def search(query: str) -> list[str]:
    """Search templates by substring match on name, alias, or content."""
Enter fullscreen mode Exit fullscreen mode

The search function checks three dimensions: template names, alias names, and the actual content of each template. This means searching for "node_modules" will find the Node template even though "node_modules" does not appear in the template name or any alias. Searching for "ide" finds JetBrains, VSCode, and any other template that happens to mention IDE-related patterns.

The Merge Engine (src/merger.py)

The merge module takes a list of template names and produces a single, clean .gitignore string. Its responsibilities are:

  1. Resolve aliases to canonical template names
  2. Deduplicate templates — if someone types python python, it processes Python once
  3. Deduplicate patterns — if Python and Node both include .env, it appears only once
  4. Preserve section headers — comment lines are never deduplicated
  5. Add section separators — blank lines between template sections for readability
def merge(names: list[str]) -> str:
    resolved: list[str] = []
    for name in names:
        key = resolve(name)
        if key is None:
            raise ValueError(
                f"Unknown template: '{name}'. "
                f"Use --list to see available templates."
            )
        if key not in resolved:
            resolved.append(key)

    seen_patterns: set[str] = set()
    sections: list[list[str]] = []

    for key in resolved:
        lines = TEMPLATES[key]
        section: list[str] = []
        for line in lines:
            stripped = line.strip()
            if stripped.startswith("#"):
                section.append(line)
                continue
            if not stripped:
                continue
            if stripped not in seen_patterns:
                seen_patterns.add(stripped)
                section.append(line)
        if section:
            sections.append(section)

    output_parts: list[str] = []
    for i, section in enumerate(sections):
        if i > 0:
            output_parts.append("")
        output_parts.extend(section)

    return "\n".join(output_parts) + "\n"
Enter fullscreen mode Exit fullscreen mode

The deduplication strategy is "first occurrence wins." If both Python and Node include .env, the Python section keeps it (because Python was listed first), and the Node section silently drops it. This preserves the user's intent — the first template they listed gets priority for shared patterns.

This is different from naive deduplication that might remove all duplicates and collect them into a separate section. I tried that approach and found it produced confusing output: patterns were separated from their logical context, making it hard to understand why a particular pattern was present.

The CLI Layer (src/cli.py)

The CLI module uses argparse to define the command-line interface and routes to the appropriate function:

def run(argv: list[str] | None = None) -> int:
    parser = build_parser()
    args = parser.parse_args(argv)

    if args.list:
        # Column-formatted template listing
        ...
        return 0

    if args.search:
        # Filtered template search
        ...
        return 0

    if not args.templates:
        parser.print_help()
        return 1

    try:
        output = merge(args.templates)
    except ValueError as e:
        print(f"Error: {e}", file=sys.stderr)
        return 1

    if args.output:
        mode = "a" if args.append else "w"
        with open(args.output, mode, encoding="utf-8") as f:
            f.write(output)
    else:
        sys.stdout.write(output)

    return 0
Enter fullscreen mode Exit fullscreen mode

The function returns an integer exit code rather than calling sys.exit() directly. This makes testing straightforward — tests call run() with a list of arguments and check the return code, without needing to catch SystemExit exceptions.

The --list output formats template names in columns, adapting to terminal width. The --output flag supports both overwrite (default) and append (--append) modes, so you can incrementally build a .gitignore from multiple commands if needed.

Design Decisions Worth Explaining

Why Embedded Templates Instead of GitHub API

The GitHub gitignore repository at github/gitignore is the canonical source, and many tools fetch from it. I chose to embed templates for three reasons:

  1. Offline first. Developers working on trains, planes, or behind corporate firewalls need their tools to work without internet access. A .gitignore generator that requires a network request defeats much of its purpose.

  2. Speed. An embedded dictionary lookup is measured in microseconds. An API call is measured in hundreds of milliseconds at best. When you are scaffolding a project and running multiple commands in sequence, that latency adds up.

  3. Reliability. No API rate limits, no authentication tokens, no SSL certificate issues, no DNS resolution failures. The tool either works or it does not — there is no "works sometimes" failure mode.

The cost is that templates can become stale. But gitignore patterns for established languages change rarely. Python has used __pycache__/ and *.pyc for over a decade. Node has used node_modules/ since its inception. The patterns that do change (like adding .pnpm-store/ for newer package managers) can be added in a release.

Why argparse Instead of click or typer

The project has zero runtime dependencies. Using argparse from the standard library means the tool works on any Python 3.10+ installation without pip install. This is particularly valuable for the target audience — developers who might want to use this tool during project setup, before they have even created a virtual environment.

Click and typer are excellent libraries, but they introduce a dependency chain that complicates distribution. A stdlib-only tool can be vendored, copied, or run directly from a checkout.

Why Deduplication Matters

Consider combining Python, Node, and TypeScript templates. All three include patterns like .env, dist/, and build/. Without deduplication, you would get three copies of .env scattered through your .gitignore file. This is not harmful to git — it processes the file top-to-bottom and applies all matching patterns — but it is confusing to humans reading the file.

More importantly, duplicate patterns make the file harder to maintain. When you want to add an exception (!build/important/), you need to add it after every build/ entry, or you might be confused about which one is "active." A deduplicated file has exactly one build/ entry, and the exception goes right after it.

Template Coverage

The tool ships with 51 templates organized into six categories:

Languages (27): Python, Node.js, Rust, Go, Java, PHP, Ruby, Swift, Kotlin, C, C++, Dart, Elixir, Haskell, Scala, R, Julia, Lua, Perl, TeX, C#, F#, TypeScript, Zig, Erlang, Clojure, Objective-C

Game Engines (3): Unity, Unreal Engine, Godot

Mobile (3): Android, Xcode, Flutter

Editors (5): JetBrains, VSCode, Vim, Emacs, Sublime Text

Operating Systems (3): macOS, Windows, Linux

Build Tools & Frameworks (10): Terraform, Docker, CMake, Gradle, Maven, Bazel, Vagrant, Ansible, Rails, Next.js

Each template is curated to include the patterns that matter most for that ecosystem. The Python template, for example, includes not just __pycache__/ and *.pyc but also patterns for pytest (.pytest_cache/), mypy (.mypy_cache/), coverage (.coverage, htmlcov/), tox (.tox/), and virtual environments (.venv/, venv/, ENV/).

Testing Strategy

The project has 32 pytest tests covering all three modules:

tests/test_templates.py  — 12 tests
  Template count ≥ 50, all have entries, all start with comment,
  resolve direct/alias/case-insensitive/unknown, list_all sorted,
  search by name/alias/content/no-results, aliases validity

tests/test_merger.py     — 10 tests
  Single template, multiple templates, deduplication within/across,
  section headers, alias resolution, unknown template error,
  empty list, section separators, duplicate template names

tests/test_cli.py        — 10 tests
  --list, --search hit/miss, stdout generation single/multiple,
  --output file write, --append mode, unknown template error,
  no-args help
Enter fullscreen mode Exit fullscreen mode

The merge deduplication tests are particularly important. They verify that patterns shared across templates appear exactly once in the output, that section headers are always preserved (even for sections that had all their patterns deduplicated away), and that the order of templates affects which section "owns" a shared pattern.

The CLI tests use capsys for stdout capture and tempfile for file output tests, ensuring no test artifacts are left on disk.

Docker Support

The Dockerfile uses a multi-stage build to minimize the final image:

FROM python:3.12-alpine AS builder
WORKDIR /app
COPY . .

FROM alpine:3.19
RUN apk add --no-cache python3 && \
    addgroup -S app && adduser -S app -G app
WORKDIR /app
COPY --from=builder /app /app
USER app
ENTRYPOINT ["python3", "main.py"]
Enter fullscreen mode Exit fullscreen mode

The builder stage uses the full Python image for any potential build steps. The runtime stage uses plain Alpine with just the python3 package — no pip, no setuptools, no wheel. The image runs as a non-root user for security.

$ docker build -t gitignore-gen .
$ docker run --rm gitignore-gen python node macos > .gitignore
Enter fullscreen mode Exit fullscreen mode

Extending with Custom Templates

The template system is designed to be extended. To add a new template, you add an entry to the TEMPLATES dictionary in src/templates.py:

TEMPLATES: dict[str, list[str]] = {
    # ... existing templates ...
    "myframework": [
        "# My Framework",
        ".myframework/",
        "build/",
        "*.compiled",
    ],
}
Enter fullscreen mode Exit fullscreen mode

If you want aliases, add them to the ALIASES dictionary:

ALIASES: dict[str, str] = {
    # ... existing aliases ...
    "mf": "myframework",
}
Enter fullscreen mode Exit fullscreen mode

The search function will automatically index new templates — no registration step needed.

Practical Workflow

Here is how I use this tool in practice:

# New Python web project
$ mkdir my-project && cd my-project
$ git init
$ gitignore-gen python node macos vscode --output .gitignore
$ git add .gitignore && git commit -m "initial commit"

# Later, add JetBrains patterns for a teammate
$ gitignore-gen jetbrains --output .gitignore --append

# Check what templates are available for a Rust project
$ gitignore-gen --search rust
Enter fullscreen mode Exit fullscreen mode

The append mode is useful when different team members use different editors. You start with the language and OS templates, then each developer can append their editor template without overwriting the existing patterns.

Lessons Learned

Curating templates is harder than writing code. The merge engine took an afternoon. Curating 51 templates with the right patterns — not too many, not too few — took significantly longer. Every language ecosystem has its own conventions, its own build tools, its own editor integrations. Getting the Python template right means knowing about pytest, mypy, ruff, tox, nox, coverage, hypothesis, and half a dozen virtual environment conventions.

Deduplication needs ordering semantics. My first implementation used a set to collect all unique patterns and dumped them in alphabetical order. The output was correct but unreadable — you could not tell which patterns came from which template. The "first occurrence wins" strategy preserves the logical grouping that makes .gitignore files maintainable.

Aliases are a UX feature, not a code feature. The alias system adds zero complexity to the merge engine — it is just a dictionary lookup in the resolution step. But it dramatically improves the user experience. gitignore-gen py js mac is much faster to type than gitignore-gen python node macos, and developers reach for short names instinctively.


The source code is available on GitHub. It is a single pip install pytest away from running the full test suite, and a single python main.py away from generating your next .gitignore.

Top comments (0)