10 Git-Befehle und ihre technischen Grundlagen

Während die meisten Git-Nutzer mit den High-Level-Befehlen wie git commit, git branch und git merge arbeiten, liegt unter der Oberfläche ein elegantes System von miteinander verknüpften Objekten. Dieses Kapitel taucht tief in die technischen Grundlagen ein und zeigt, was tatsächlich passiert, wenn wir Git-Befehle ausführen. Wir werden die Objekt-Datenbank direkt inspizieren, durch Commit-Hierarchien navigieren und verstehen, wie Git Daten auf der untersten Ebene organisiert.

10.1 Die Git-Objekt-Datenbank: Ein praktischer Blick

Git speichert alle Daten als Objekte in .git/objects/. Diese Objekte sind durch ihre SHA-1-Hashes identifiziert und bilden eine komplexe, aber logische Struktur. Das Werkzeug git cat-file ist unser Fenster in diese Welt – es erlaubt uns, rohe Git-Objekte zu inspizieren, ihre Typen zu identifizieren und ihre Inhalte anzuzeigen.

10.1.1 Grundlegende cat-file Befehle

Bevor wir in die Tiefe gehen, die essentiellen git cat-file Optionen:

# Zeigt den Typ eines Objekts
git cat-file -t <hash>

# Zeigt die Größe eines Objekts
git cat-file -s <hash>

# Zeigt den Inhalt eines Objekts (pretty-printed)
git cat-file -p <hash>

# Zeigt rohen Inhalt eines Objekts
git cat-file <type> <hash>

Die -p Option ist besonders nützlich – sie formatiert Objekte leserlich, interpretiert Commits, Trees und Tags automatisch korrekt.

10.2 Anatomie eines Commits: Von außen nach innen

Beginnen wir mit einem einfachen Beispiel-Repository, um die Struktur zu erkunden. Stellen wir uns vor, wir haben folgendes Projekt:

my-project/
├── README.md
├── src/
│   ├── main.py
│   └── utils/
│       └── helper.py
└── tests/
    └── test_main.py

Nachdem wir diese Struktur erstellt und committed haben, können wir die Git-Objekte inspizieren.

10.2.1 Schritt 1: Den aktuellen Commit finden

Zunächst identifizieren wir den neuesten Commit:

$ git log --oneline -1
7f8a3d2 Add initial project structure

Der Hash 7f8a3d2 (verkürzte Form) identifiziert unseren Commit eindeutig. Den vollständigen Hash erhalten wir mit:

$ git rev-parse HEAD
7f8a3d2c4e6b1a9f5d8c3e7b2a4f6d1c8e5b9a3c

10.2.2 Schritt 2: Das Commit-Objekt inspizieren

Jetzt schauen wir uns an, was tatsächlich in diesem Commit gespeichert ist:

$ git cat-file -t 7f8a3d2
commit

$ git cat-file -p 7f8a3d2
tree 4d5f2e1c3b6a8f7d9e2b5c1a4f8d6e3b7a9c2d1f
parent a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0
author John Doe <john@example.com> 1704067200 +0100
committer John Doe <john@example.com> 1704067200 +0100

Add initial project structure

This commit sets up the basic project layout with
source code, utilities, and test directories.

Analysieren wir dieses Commit-Objekt im Detail:

tree 4d5f2e1c…: Dies ist der SHA-1-Hash des Root-Tree-Objekts. Dieser Tree repräsentiert den kompletten Zustand des Projektverzeichnisses zum Zeitpunkt dieses Commits. Er enthält Referenzen zu allen Dateien und Unterverzeichnissen.

parent a1b2c3d4…: Der Hash des vorherigen Commits. Diese Eltern-Beziehung bildet die lineare Geschichte (bzw. bei Merges den verzweigten Graph) des Repositories. Der allererste Commit in einem Repository hat keine parent-Zeile.

author / committer: Zwei getrennte Rollen. Der Author hat die Änderungen ursprünglich geschrieben, der Committer hat sie ins Repository aufgenommen. In den meisten Fällen sind beide identisch, aber bei Patches oder Rebases können sie divergieren.

Timestamps: Unix-Timestamps (Sekunden seit 1970-01-01) plus Zeitzone. 1704067200 +0100 bedeutet 1. Januar 2024, 00:00:00 Uhr in der Zeitzone UTC+1.

Commit-Message: Der Text nach der Leerzeile. Die erste Zeile ist traditionell die Zusammenfassung (maximal ~50 Zeichen), gefolgt von einer Leerzeile und einem optionalen detaillierten Body.

10.2.3 Schritt 3: Den Root-Tree untersuchen

Der Tree, auf den der Commit verweist, ist die Wurzel unseres Dateisystems:

$ git cat-file -t 4d5f2e1c
tree

$ git cat-file -p 4d5f2e1c
100644 blob a906cb2a4a904a152e80877d4088654daad0c859    README.md
040000 tree 3f7e8d2c1a5b6f9e8d3c7a2b1f4e6d8c5a9b3e7f    src
040000 tree 9b8a7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b    tests

Jede Zeile in einem Tree-Objekt folgt dem Format:

<mode> <type> <hash>    <name>

Mode: Unix-Dateiberechtigungen in Oktal. - 100644: Normale Datei (nicht ausführbar) - 100755: Ausführbare Datei - 040000: Verzeichnis (immer ein Tree) - 120000: Symbolic Link - 160000: Gitlink (Submodule)

Die ersten drei Ziffern repräsentieren den Dateityp (100 = regular file, 040 = directory, 120 = symlink), die letzten drei Ziffern sind die Permissions im klassischen Unix-Format.

Type: Entweder blob (Datei) oder tree (Verzeichnis).

Hash: SHA-1 des referenzierten Objekts.

Name: Datei- oder Verzeichnisname (ohne Pfad – Trees speichern nur ihre direkten Kinder).

10.2.4 Schritt 4: In einen Subtree hinabsteigen

Schauen wir uns das src/-Verzeichnis an:

$ git cat-file -p 3f7e8d2c
100644 blob 8ab686eafeb1f44702738c8b0f24f2567c36da6d    main.py
040000 tree 5c9d2e3f4a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d    utils

Das src/-Verzeichnis enthält eine Datei (main.py) und ein Unterverzeichnis (utils/). Beachte, dass der Tree keine Information über seinen eigenen Pfad hat – er kennt nur seine Kinder. Die hierarchische Struktur entsteht durch Trees, die auf andere Trees verweisen.

Steigen wir noch eine Ebene tiefer:

$ git cat-file -p 5c9d2e3f
100644 blob 2f5a8c7d3e9b1f4a6c8d0e2b5a7c9f1d3e5b7a9c    helper.py

Der utils/-Tree enthält nur eine Datei. Dies ist das Ende unseres Abstiegs – wir sind bei einem Leaf-Node angelangt.

10.2.5 Schritt 5: Blob-Objekte – der tatsächliche Inhalt

Ein Blob enthält die rohen Dateiinhalte. Schauen wir uns README.md an:

$ git cat-file -t a906cb2a
blob

$ git cat-file -p a906cb2a
# My Project

This is an example project demonstrating Git internals.

## Structure

- `src/`: Source code
- `tests/`: Test files

$ git cat-file -s a906cb2a
123

Der Blob enthält genau den Textinhalt der Datei – nichts mehr, nichts weniger. Keine Metadaten über Dateinamen, Permissions oder Timestamps. Diese Informationen leben im Tree, der auf den Blob verweist.

Ein wichtiges Detail: Blob-Hashes hängen nur vom Inhalt ab. Wenn zwei Dateien in unterschiedlichen Verzeichnissen oder Branches denselben Inhalt haben, teilen sie sich denselben Blob. Git speichert ihn nur einmal:

# Zwei Dateien mit identischem Inhalt
$ echo "Hello, World!" > file1.txt
$ echo "Hello, World!" > file2.txt
$ git add file1.txt file2.txt
$ git ls-files -s
100644 a0b65939670bc2c010f4d5d6a0b3e4e46de0edc0 0       file1.txt
100644 a0b65939670bc2c010f4d5d6a0b3e4e46de0edc0 0       file2.txt

Beide Dateien referenzieren denselben Blob-Hash a0b65939... – Content-Addressable Storage in Aktion.

10.2.6 Schritt 6: Die rohe Struktur eines Git-Objekts

Bisher haben wir -p (pretty-print) verwendet. Schauen wir uns an, wie Git Objekte tatsächlich speichert. Git-Objekte haben ein internes Format:

<type> <size>\0<content>

Dabei ist \0 ein Null-Byte (ASCII 0). Den Hash berechnet Git über genau diesen String.

Wir können dies mit Low-Level-Tools nachvollziehen:

# Erstelle einen simplen Blob
$ echo -n "Hello, World!" | git hash-object --stdin
a0b65939670bc2c010f4d5d6a0b3e4e46de0edc0

# Manuell berechnen (Pseudo-Code)
content = "Hello, World!"
header = "blob #{content.length}\0"
store = header + content
sha1(store) = "a0b65939670bc2c010f4d5d6a0b3e4e46de0edc0"

Git komprimiert diese Objekte mit zlib und speichert sie unter .git/objects/a0/b65939.... Die ersten zwei Zeichen des Hashes bilden ein Unterverzeichnis, der Rest ist der Dateiname – eine Optimierung, um zu vermeiden, dass tausende Dateien in einem Verzeichnis liegen.

# Direkte Inspektion (erfordert zlib-Dekompression)
$ python3 -c "import zlib; print(zlib.decompress(open('.git/objects/a0/b65939670bc2c010f4d5d6a0b3e4e46de0edc0', 'rb').read()))"
b'blob 13\x00Hello, World!'

Das Objekt beginnt mit blob 13\0 (wobei 13 die Länge von “Hello, World!” ist), gefolgt vom Inhalt.

10.3 Parent-Beziehungen: Die Git-Historie als Graph

Commits bilden eine Kette (oder bei Merges: einen Graphen) durch ihre Parent-Referenzen. Schauen wir uns eine längere Historie an:

$ git log --oneline --graph
* 7f8a3d2 (HEAD -> main) Add initial project structure
* a1b2c3d Add README
* 3c4d5e6 Initial commit

Inspizieren wir die Commits von neu nach alt:

$ git cat-file -p 7f8a3d2
tree 4d5f2e1c3b6a8f7d9e2b5c1a4f8d6e3b7a9c2d1f
parent a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0
author John Doe <john@example.com> 1704067200 +0100
committer John Doe <john@example.com> 1704067200 +0100

Add initial project structure

$ git cat-file -p a1b2c3d
tree 8c7d2e9f1a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8
parent 3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2
author Jane Smith <jane@example.com> 1704063600 +0100
committer Jane Smith <jane@example.com> 1704063600 +0100

Add README

$ git cat-file -p 3c4d5e6
tree 2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1
author John Doe <john@example.com> 1704060000 +0100
committer John Doe <john@example.com> 1704060000 +0100

Initial commit

Beachte: Der Initial-Commit (3c4d5e6) hat keine parent-Zeile – er ist die Wurzel der Historie.

Diese einfache Verkettung durch Parent-Hashes ermöglicht Git, die gesamte Historie zu rekonstruieren. git log folgt einfach den Parent-Pointern rückwärts, bis keine Parents mehr existieren.

10.3.1 Merge-Commits: Zwei Parents

Bei einem Merge entstehen Commits mit mehreren Parents:

$ git log --oneline --graph
*   9e8f7d6 (HEAD -> main) Merge branch 'feature'
|\  
| * 6c5d4e3 (feature) Add feature B
| * 4a3b2c1 Add feature A
* | 8d7c6b5 Fix bug in main
|/  
* 7f8a3d2 Add initial project structure

Der Merge-Commit:

$ git cat-file -p 9e8f7d6
tree 5f4e3d2c1b0a9f8e7d6c5b4a3f2e1d0c9b8a7f6
parent 8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b3a2f1e0d9
parent 6c5d4e3b2a1f0e9d8c7b6a5f4e3d2c1b0a9f8e7
author John Doe <john@example.com> 1704070800 +0100
committer John Doe <john@example.com> 1704070800 +0100

Merge branch 'feature'

Conflicts resolved in src/main.py

Zwei parent-Zeilen! Der erste Parent ist der Commit auf main (wo wir standen beim Merge), der zweite ist der Tip des Feature-Branches. Diese Dual-Parent-Struktur ist wie Git Merge-Historie repräsentiert.

10.4 Praktische Exploration: Ein vollständiges Beispiel

Erstellen wir ein Mini-Repository und erforschen seine Objekte vollständig:

# Neues Repository
$ mkdir git-internals-demo && cd git-internals-demo
$ git init
Initialized empty Git repository in /path/to/git-internals-demo/.git/

# Erste Datei
$ echo "Hello Git" > greeting.txt
$ git add greeting.txt
$ git commit -m "Add greeting"
[main (root-commit) 4f3d2c1] Add greeting
 1 file changed, 1 insertion(+)
 create mode 100644 greeting.txt

# Unterverzeichnis mit weiterer Datei
$ mkdir docs
$ echo "# Documentation" > docs/README.md
$ git add docs/
$ git commit -m "Add documentation"
[main 7e6d5c4] Add documentation
 1 file changed, 1 insertion(+)
 create mode 100644 docs/README.md

Jetzt haben wir zwei Commits. Erkunden wir:

# Aktueller Commit
$ git cat-file -p HEAD
tree a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0
parent 4f3d2c1a0b9e8d7c6b5a4f3e2d1c0b9a8f7e6d5
author Demo User <demo@example.com> 1704071400 +0100
committer Demo User <demo@example.com> 1704071400 +0100

Add documentation

# Root-Tree des aktuellen Commits
$ git cat-file -p HEAD^{tree}
040000 tree 8c7b6a5f4e3d2c1b0a9f8e7d6c5b4a3f2e1d0c9    docs
100644 blob 2f1e0d9c8b7a6f5e4d3c2b1a0f9e8d7c6b5a4f3    greeting.txt

# Subtree "docs/"
$ git cat-file -p 8c7b6a5f
100644 blob 5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0d9e8f7a6    README.md

# Blob "greeting.txt"
$ git cat-file -p 2f1e0d9c
Hello Git

# Blob "docs/README.md"
$ git cat-file -p 5a4b3c2d
# Documentation

# Parent-Commit
$ git cat-file -p HEAD^
tree 9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c3b2a1f0
author Demo User <demo@example.com> 1704067800 +0100
committer Demo User <demo@example.com> 1704067800 +0100

Add greeting

# Root-Tree des Parent-Commits
$ git cat-file -p 9f8e7d6c
100644 blob 2f1e0d9c8b7a6f5e4d3c2b1a0f9e8d7c6b5a4f3    greeting.txt

Beachte: greeting.txt hat in beiden Commits denselben Blob-Hash (2f1e0d9c...), weil der Inhalt unverändert ist. Der zweite Commit fügt nur docs/ hinzu, ohne greeting.txt zu duplizieren.

Die gestrichelte Linie symbolisiert, dass beide Trees auf denselben Blob verweisen.

10.5 Effizienz durch Content-Addressing

Diese Content-Addressable-Struktur hat elegante Effizienz-Eigenschaften:

Automatische Deduplication: Wenn hundert Branches dieselbe Datei enthalten, existiert nur ein Blob. Jeder Tree referenziert denselben Hash.

Schnelle Diffs: Um Unterschiede zwischen zwei Commits zu finden, vergleicht Git ihre Root-Trees. Wenn ein Subtree denselben Hash hat, ist das gesamte Unterverzeichnis identisch – Git muss nicht tiefer schauen.

# Vergleiche Trees direkt
$ git diff-tree 4f3d2c1 7e6d5c4
:000000 040000 0000000000000000000000000000000000000000 8c7b6a5f4e3d2c1b0a9f8e7d6c5b4a3f2e1d0c9 A      docs

Git sieht: Das docs/-Verzeichnis wurde hinzugefügt (A), greeting.txt ist unverändert (kein Output = keine Änderung).

Sichere Historie: Jede Änderung, selbst an einem einzigen Byte, propagiert durch Tree-Hashes bis zum Commit-Hash. Manipulationen sind sofort erkennbar.

10.6 Fortgeschrittene Inspektion: Das gesamte Repository kartieren

Wir können alle Objekte im Repository auflisten:

# Alle Objekte finden
$ find .git/objects -type f | head -5
.git/objects/2f/1e0d9c8b7a6f5e4d3c2b1a0f9e8d7c6b5a4f3
.git/objects/4f/3d2c1a0b9e8d7c6b5a4f3e2d1c0b9a8f7e6d5
.git/objects/5a/4b3c2d1e0f9a8b7c6d5e4f3a2b1c0d9e8f7a6
.git/objects/7e/6d5c4b3a2f1e0d9c8b7a6f5e4d3c2b1a0f9e8
.git/objects/8c/7b6a5f4e3d2c1b0a9f8e7d6c5b4a3f2e1d0c9

# Für jedes Objekt: Typ und Größe
$ for obj in $(find .git/objects -type f | sed 's|.git/objects/||; s|/||'); do
    echo "$obj: $(git cat-file -t $obj) ($(git cat-file -s $obj) bytes)"
done
2f1e0d9c8b7a6f5e4d3c2b1a0f9e8d7c6b5a4f3: blob (10 bytes)
4f3d2c1a0b9e8d7c6b5a4f3e2d1c0b9a8f7e6d5: commit (234 bytes)
5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0d9e8f7a6: blob (16 bytes)
7e6d5c4b3a2f1e0d9c8b7a6f5e4d3c2b1a0f9e8: commit (267 bytes)
8c7b6a5f4e3d2c1b0a9f8e7d6c5b4a3f2e1d0c9: tree (67 bytes)
...

Diese Low-Level-Perspektive zeigt: Git ist eine Content-Addressable Filesystem-Datenbank mit einer dünnen Versionskontroll-Schicht darüber.

10.7 Referenzen und Branches: Pointer auf Commits

Branches und Tags sind keine komplexen Objekte – sie sind einfache Textdateien, die Commit-Hashes enthalten:

$ cat .git/refs/heads/main
7e6d5c4b3a2f1e0d9c8b7a6f5e4d3c2b1a0f9e8

$ cat .git/HEAD
ref: refs/heads/main

Ein Branch ist buchstäblich eine 40-Zeichen-Textdatei. Das Erstellen, Löschen oder Verschieben eines Branches ist eine einfache Dateioperation – deshalb sind Git-Branches so leichtgewichtig.

# Neuer Branch = neue Datei mit Commit-Hash
$ git branch feature
$ cat .git/refs/heads/feature
7e6d5c4b3a2f1e0d9c8b7a6f5e4d3c2b1a0f9e8

# Branch löschen = Datei löschen
$ git branch -d feature
Deleted branch feature (was 7e6d5c4).
$ ls .git/refs/heads/
main

10.8 Praktische Implikationen für Workflows

Dieses tiefe Verständnis hat praktische Konsequenzen:

Commits sind unveränderlich: Wenn du versuchst, einen Commit zu “ändern” (z.B. mit git commit --amend), erstellt Git tatsächlich einen neuen Commit mit neuem Hash. Der alte Commit existiert weiterhin (wird nur von keinem Branch mehr referenziert).

Branches sind billig: Da sie nur Textdateien sind, ist paralleles Arbeiten in hunderten Branches kein Problem. Feature-Branch-Workflows sind praktisch “kostenlos”.

Rollbacks sind sicher: Zu jedem Commit zurückzukehren bedeutet einfach, einen Tree auszuchecken. Der exakte Zustand ist garantiert reproduzierbar.

CI/CD kann Objekte cachen: CI-Systeme können Git-Objekte zwischen Runs cachen. Wenn ein Commit bereits gebaut wurde, kann der Build wiederverwendet werden – der Hash garantiert Identität.

10.9 Die Eleganz der Einfachheit

Git’s Datenmodell ist elegant einfach: - Blobs: Dateiinhalte - Trees: Verzeichnisstrukturen - Commits: Snapshots mit Metadaten - Tags: Benannte Referenzen

Alle verknüpft durch SHA-1-Hashes, organisiert als Directed Acyclic Graph. Diese Simplizität ermöglicht robuste, schnelle und flexible Versionskontrolle.

Das nächste Mal, wenn du git commit ausführst, weißt du: Git erstellt einen Commit-Objekt mit einem Tree-Hash, fügt Parent-Referenzen hinzu, berechnet den SHA-1 über die gesamte Struktur und aktualisiert den Branch-Pointer. Alles andere – die Benutzeroberfläche, die Workflows, die Tools – ist darüber gebaut.

Diese Low-Level-Perspektive ist nicht nur akademisch interessant. Sie erklärt Git’s Verhalten in Edge-Cases, ermöglicht Troubleshooting wenn Dinge schiefgehen, und offenbart, warum bestimmte Operationen schnell oder langsam sind. Es ist die Grundlage, auf der alle High-Level-Git-Workflows aufbauen.