gitignore-excludes: .gitignore-Regeln für restic und borg

Ein kleines Werkzeug, das Git in jedem Working Tree unterhalb eines Verzeichnisses nach ignorierten Pfaden fragt und sie als Exclude-Datei für restic oder borg schreibt.

Das Problem

restic und borg haben keine Option, .gitignore-Dateien zu lesen. Wer ein Verzeichnis mit Quellcode sichert, sichert entweder jedes node_modules, jede virtuelle Umgebung und jeden Build-Ordner mit oder pflegt Exclude-Muster von Hand.

Die Anfrage ist alt:

  • restic/restic#1514, "Use .gitignore (or other VCS) for excluding files", hat 70 Reaktionen und 27 Kommentare und ist seit Dezember 2017 offen. Im Thread stimmte ein restic-Beitragender für "an easy way to use a suitable external tool", statt die Funktion in restic einzubauen.
  • restic/restic#4227, der Pull Request mit einer Umsetzung, hat 20 Reaktionen und ist seit März 2023 ohne Review offen.
  • borgbackup/borg#641 hat 24 Reaktionen; borg#937, "exclude files specified in .gitignore", wurde ohne Umsetzung geschlossen.

Die dort geposteten Umgehungen leiten rg --files oder fd in restic backup --files-from-verbatim. Sie werten Ignore-Dateien mit einer anderen Implementierung aus als Git, und jede gelistete Datei wird zu einem Snapshot-Pfad. Die Pfadliste ändert sich also, sobald eine Datei hinzukommt oder wegfällt (restic#2246 beschreibt das daraus folgende Problem bei der Erkennung des Eltern-Snapshots). Ein anderer Ansatz schreibt CACHEDIR.TAG-Dateien in ignorierte Verzeichnisse. Das verändert die Working Trees und kann keine einzelnen Dateien ausschließen.

Wie es funktioniert

gitignore-excludes durchläuft die angegebenen Verzeichnisse und führt in jedem Verzeichnis, das .git enthält, diesen Befehl aus:

git ls-files -z --others --ignored --exclude-standard --directory

Das Ergebnis folgt damit den Regeln von Git selbst, einschließlich verschachtelter .gitignore-Dateien, Negation (!keep.log), .git/info/exclude und core.excludesFile. Versionierte Dateien werden nie ausgeschlossen, auch wenn ein Muster auf sie passt. Ein vollständig ignoriertes Verzeichnis wird zu einer Zeile, und der Durchlauf steigt nicht hinein. Jeder Pfad wird als absolutes, maskiertes Muster geschrieben, das nur auf diesen Pfad passt. Für restic werden *, ?, [ und \ maskiert, und $ wird zu $$, weil restic Umgebungsvariablen in Exclude-Dateien ersetzt. Für borg werden die Pfade zu wörtlichen Pfadpräfixen pp:, Namen mit Leerraum am Ende zu fm:-Mustern mit maskierten Zeichen. --keep GLOB behält ignorierte Einträge, deren Name passt, zum Beispiel .env*; ein ignoriertes Verzeichnis mit einer behaltenen Datei wird dann Eintrag für Eintrag ausgeschlossen.

Beispiel aus dem README:

$ gitignore-excludes ~/code > /tmp/code-excludes.txt
gitignore-excludes: 11 working trees, 27 ignored paths
$ restic backup ~/code --exclude-file /tmp/code-excludes.txt

$ head -4 /tmp/code-excludes.txt
# generated by gitignore-excludes; regenerate before each backup
/home/me/code/api/.venv
/home/me/code/api/src/api/__pycache__
/home/me/code/web/node_modules

Bei einem Ordner mit 11 Git-Repositories sank das restic-Backup von 4253 Dateien (86,6 MiB) auf 604 Dateien (1,1 MiB). Das Werkzeug braucht Python 3.10 oder neuer und git, sonst keine Abhängigkeiten.

Tests

Die Tests bauen einen Fixture-Baum mit zwei Repositories, eines davon im anderen verschachtelt. Er enthält verschachtelte .gitignore-Dateien, ein negiertes Muster, eine versionierte Datei, auf die ein Ignore-Muster passt, ignorierte Verzeichnisse, ein Verzeichnis außerhalb jedes Repositorys und Dateinamen mit *, ?, [, \, $ sowie Leerzeichen am Anfang oder Ende. Die Unit-Tests prüfen, dass der Scan genau die erwartete Menge an Pfaden liefert, dass ein Repository in einem ignorierten Verzeichnis nicht gesondert durchsucht wird, wie --keep ein ignoriertes Verzeichnis aufteilt und die Maskierung für beide Formate.

Die End-to-End-Tests sichern den Fixture-Baum mit den echten Programmen restic und borg und der erzeugten Exclude-Datei und vergleichen den Inhalt des Snapshots mit den Dateien, die Git nicht ignoriert. Der restic-Test läuft zusätzlich mit --keep '.env*' und setzt HOME auf einen Platzhalter, um zu prüfen, dass ein $HOME in einem Dateinamen nicht ersetzt wird. Das CI führt die Tests auf Python 3.10 bis 3.13 mit restic 0.19.1 und borg 1.4.5 aus.

Grenzen

  • Ignorierte Dateien sind oft Geheimnisse wie .env-Dateien und Keystores, die es womöglich nirgends sonst gibt. Das README empfiehlt, einmal mit -f plain zu laufen, die Liste zu lesen und --keep-Muster zu ergänzen.
  • Die Liste enthält konkrete Pfade und muss vor jedem Backup neu erzeugt werden.
  • Werkzeug und Backup-Befehl müssen dieselben Wurzelpfade bekommen. Symlinks werden nicht verfolgt.
  • Nur Git-Working-Trees werden behandelt. Ein Repository in einem ignorierten Verzeichnis wird mit diesem ausgeschlossen.
  • Dateinamen mit Zeilenumbruch lassen sich nicht exakt schreiben; der Zeilenumbruch wird zu ?.
  • Getestet ist es nur unter Linux.

Code und Installationsanleitung liegen unter github.com/Jwrede/gitignore-excludes.