Git: History as a Merkle DAG and the Collaborative Workflow
0. Key points
Section titled “0. Key points”- Git is not a sequence of diffs. It is a collection of immutable objects addressed by the hash of their own content. A commit holds a pointer to the entire file tree at that moment, together with pointers to its parent commits.
- Every object is serialised in a form that embeds the hashes of its children, so the object graph is a Merkle DAG. The consequence is that checking a single 40-character commit hash pins down the whole reachable history and every file in it (Theorem 3.3).
- A branch is nothing but a named pointer to a commit. That is why
git branchis cheap. git mergecomputes the merge base of two commits (a maximal common ancestor) and then performs a 3-way merge of the base, our version and their version. Saying that the merge base is HEAD itself is the same as saying that HEAD is an ancestor of the other commit, and that is what fast-forward really is (Proposition 5.3).- The merge base need not be unique. In a history known as a criss-cross there are two or more of them, and which one is chosen changes the outcome of the merge (Proposition 5.5). Git’s default strategy handles this by merging the bases with each other to produce a virtual base.
- Rebasing rewires parents, so it necessarily produces different commits with different hashes (Proposition 5.7). This is exactly why one must not rebase a shared branch.
1. Motivation: what problem does version control solve?
Section titled “1. Motivation: what problem does version control solve?”report.docx, report_v2.docx, report_v2_revised.docx, report_final.docx, report_final_really_final.docx. Everyone has produced a sequence of file names like this at some point. It is a naive form of version control, and up to a point it genuinely works. But it cannot answer the following three questions.
- What is the difference between
report_v2.docxandreport_v2_revised.docx? File names record no differences. - Was
report_final.docxmade fromreport_v2.docxor fromreport_v2_revised.docx? In other words, which file is a descendant of which? - When two people edit
report_v2.docxat the same time and save separately, how do we keep both sets of changes?
The first is the problem of differences, the second the problem of the structure of history, the third the problem of integrating concurrent edits. A version control system (VCS) is a tool that handles all three at once.
Historically the three were solved in that order. In the early 1970s, Marc Rochkind at Bell Labs built SCCS, which established the idea of collecting the revision history of a file into a single archive file. In the early 1980s, Walter Tichy at Purdue built RCS, which improved on this by keeping the newest version in full and accumulating the differences that lead backwards into the past (reverse deltas). Both sidestep the third problem with locking: whoever wants to edit takes an exclusive lock on the file and everybody else waits.
Locking works when there are a handful of developers. With dozens the queue collapses. So from CVS (late 1980s) onward the policy was reversed: anyone may edit freely, and conflicts are integrated afterwards. This is the same idea as optimistic concurrency control in databases (see also Foundations of database design). The instrument of integration is the 3-way merge treated in the second half of this article.
CVS and its successor Subversion (from 2000) still carried the constraint of centralisation. The history lived only on the server, and committing required the network. Creating a branch meant copying a directory on the server, which was an expensive operation.
In April 2005 the Linux kernel developers lost free use of BitKeeper, the commercial distributed VCS they had been using. Linus Torvalds wrote a replacement in a few weeks. That is Git. The design requirements were explicit: it had to be fast at the scale of tens of thousands of files and hundreds of thousands of commits, every operation had to work without a network, and tampering with history had to be detectable. The third requirement is what turned Git from a mere file-history manager into a data structure built on cryptographic hashing.
2. Preliminaries: hash functions and content addressing
Section titled “2. Preliminaries: hash functions and content addressing”The key to understanding Git is the idea of never touching a file by name but always by the hash of its content. We first set up the apparatus.
Definition 2.1(Content-addressable store)
Let be the set of all finite byte strings and let be a positive integer. A map is a cryptographic hash function if the following properties hold (in the computational sense).
- (Collision resistance) One cannot find, with realistic computational resources, a pair with and .
- (Preimage resistance) Given , one cannot find, with realistic computational resources, an with .
Given a finite set to be stored, the scheme in which each is stored under the key and retrieved by is called a content-addressable store. In this scheme the key is determined by the content, and if the content changes the key necessarily changes with it.
For a long time Git used SHA-1 () as its . Hash values are displayed as 40 hexadecimal characters. Let us see numerically how much safety margin “collision resistance” actually buys.
Proposition 2.2(Birthday bound on the collision probability)
Assume that the output of behaves as a uniform random element of (the random oracle model). For distinct inputs , the probability that a collision occurs somewhere among them satisfies
Proof(Proposition 2.2)
For let be the event "". Since , the hypothesis makes and independent and uniform on , so
The event we want is , and since the probability of a union is at most the sum of the probabilities (Boole’s inequality),
The last inequality uses .
Let us substitute numbers. The Linux kernel repository holds on the order of objects. Estimating conservatively with and ,
a level at which accidental collisions may be treated as impossible. But this holds under the assumption that behaves like a random function; whether an attacker can deliberately construct a collision is a separate question. We return to this in Remark 2.4.
Example 2.3(Computing a Git object name by hand)
Git does not hash the file content directly. It hashes the byte string obtained by prefixing the type and the length,
(here is concatenation, is the decimal representation of the byte count of , and NUL is the single byte 0x00). Take the content test content followed by one newline, that is 13 bytes.
import hashlib
content = b"test content\n" # 13 bytesstore = b"blob " + str(len(content)).encode() + b"\x00" + contentprint(store) # b'blob 13\x00test content\n'print(hashlib.sha1(store).hexdigest())# => d670460b4b4aece5915caf5c68d12f560a9fe3e4Git itself produces the same value.
$ echo 'test content' | git hash-object --stdind670460b4b4aece5915caf5c68d12f560a9fe3e4With -w the object is actually written, producing a file at the path .git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4. The first two characters become a directory name in order to keep the number of entries in any one directory small. The content is compressed with zlib when stored, but the hash is taken over the uncompressed bytes. That is why the object name does not change when the compression scheme changes.
The same rule lets us compute the name of the tree object representing an empty directory. Its body is 0 bytes, so , and hashlib.sha1(b"tree 0\x00").hexdigest() is 4b825dc642cb6eb9a060e54bf8d69288fbee4904. This value is the same in every repository and is often used in scripts as a constant denoting “the empty tree”.
Remark 2.4(SHA-1 collisions and Git's response)
In 2017 Stevens and coauthors actually constructed a full SHA-1 collision: two distinct PDF files with the same SHA-1 value. This is an instance of the hypothesis of Proposition 2.2 failing. Git responds on two fronts. First, since 2017 Git uses by default a SHA-1 implementation with collision detection (sha1collisiondetection), which aborts with an error when it detects the computational patterns characteristic of the known attack. Second, a repository format using SHA-256 is available and can be created with git init --object-format=sha256 (at the time of writing this is still considered experimental). With the bound at becomes .
3. Git’s data model: four kinds of object and the Merkle DAG
Section titled “3. Git’s data model: four kinds of object and the Merkle DAG”Definition 3.1(Git objects)
The objects that go into Git’s store are of the following four kinds. Each has a byte string consisting of a header <type> <byte count> followed by NUL and then the body, and its name (object ID) is . Once created, an object is never modified.
| Type | Body | What it represents |
|---|---|---|
| blob | the raw file content (it carries no file name) | the content of one file |
| tree | a sequence of entries, each being <mode> <name> followed by NUL and a 20-byte object ID, sorted by name | the structure of one directory |
| commit | one tree ID, zero or more parent commit IDs, author, committer and timestamps, a blank line, and the commit message | the state of the whole project at some moment |
| tag | the ID and type of the target object, the tag name, the tagger and a message | an annotated tag (it can be signed) |
The mode of a tree entry is 100644 for a regular file, 100755 for an executable file, 40000 for a subdirectory, and 120000 for a symbolic link.
What matters is the nesting: a tree contains the IDs of blobs, and a commit contains the IDs of a tree and of its parent commits. Since the name of a child is embedded in the body of its parent, a single byte changed in a child changes the name of the parent too. This structure is called a Merkle DAG.
flowchart LR H["HEAD"] --> R["refs/heads/main"] R --> C2["commit C2"] C2 -->|parent| C1["commit C1"] C2 -->|tree| T2["tree T2"] C1 -->|tree| T1["tree T1"] T2 --> B1["blob B1 : README.md"] T2 --> B2["blob B2 : main.py (after)"] T1 --> B1 T1 --> B3["blob B3 : main.py (before)"]
Definition 3.2(References, branches and HEAD)
A reference (ref) is a name whose value is a commit ID. A reference stored at .git/refs/heads/<name> is a branch; one stored at .git/refs/tags/<name> is a tag. The file .git/HEAD is a special reference: normally it holds an indirect reference to a branch name (a single line ref: refs/heads/main), and that branch is called the current branch. The state in which HEAD holds a commit ID directly is called a detached HEAD.
Creating a commit consists of writing into the object store and overwriting the value of the current branch’s reference with the ID of . Objects are immutable; references are mutable.
In Git the only mutable things are references and the index (below); everything else is an immutable object. This separation explains almost all of Git’s behaviour. Creating a branch is instantaneous because it writes a single 41-byte file.
Theorem 3.3(A hash identifies an entire history)
Consider finite sets of objects and (for instance our clone and someone else’s repository). Identify each object with its serialisation , and assume that contains the IDs of all the child objects referenced by . Put and assume that is injective on (there is no collision inside this set). Write for the set of all objects reachable from .
Then, for and ,
That is, if the object IDs agree, then the sets of objects reachable from them, and their contents, agree completely.
Proof(Theorem 3.3)
First we check that the reference relation is a directed acyclic one. To create an object one must fix , and for that the IDs of the children of must already have been determined. Hence, along the order of creation times, a parent is always created after its children, and following a directed path of references makes the creation time strictly decrease. A cycle would make the time strictly decrease and yet return to its starting value, a contradiction. So the graph is a finite DAG, and for each vertex the maximum length of a directed path leaving is a well-defined finite number.
We argue by induction on .
Case . Then has no children, so together with the injectivity of gives , that is, as byte strings. Hence .
Case , assuming the claim for all objects with less than . From and the injectivity of we get . Since contains the type and the byte count in its header, and have the same type and the same bytes, and in particular the sequence of child object IDs appearing in the body is the same. Call that sequence , and let be the children of and those of , so that . Children are successors of , so and the induction hypothesis gives . Therefore
(the identity follows from ). This completes the induction.
What this theorem means in practice is important enough to be restated. If you receive a single commit ID over a trustworthy channel (a signed tag, spoken aloud, a separate medium), you can verify that not one byte of the entire history and of every file leading to that commit has been tampered with. Should an attacker alter one character in a file in a past commit, the ID of that blob changes, hence the ID of the tree, hence the ID of that commit, hence the IDs of every descendant commit. The command git fsck performs exactly this verification over all objects.
Example 3.4(Why snapshots do not blow up the repository)
Hearing that “a commit is a snapshot of the whole project”, one might expect a project of 1000 files committed 100 times to produce 100000 blobs. It does not. In the content-addressing scheme of Definition 2.1, the same content has the same ID. Committing a change to a single file creates exactly one new blob for the changed file, a new tree for the directory containing it, new trees for its ancestor directories, and one commit. Changing one file at depth adds objects, independently of the total number of files. In the object graph shown after Definition 3.1, this is the situation in which the blob B1 for README.md is shared by two trees.
Furthermore, once loose objects accumulate, git gc repacks them into a pack file and delta-compresses objects with similar content against each other. The important point is that these deltas are purely an optimisation of the storage format and carry no historical meaning. The delta base need not be the parent of a commit; it is simply whichever object happens to be similar. In RCS and Subversion the differences were the history; in Git the history is carried by the parent pointers of commits, and differences are merely a matter of compression. Thanks to this separation, what git log displays and what git diff computes can be defined independently of the storage format. The idea of “keeping one copy of identical content and sharing it between versions” is not peculiar to Git: container image layers (Definition 4.1[Docker and Kubernetes]) hold down storage and transfer costs by the same mechanism.
Remark 3.5(The index (the staging area))
.git/index is a binary file holding a draft of the tree that the next commit will create. It lists the paths in the working tree together with the ID of the corresponding blob, the mode, the modification time and so on. git add reads a file from the working tree, writes a blob, and updates the corresponding line of the index. git commit builds a tree object from the index and creates a commit pointing at it. Having three states — working tree, index and HEAD — is what makes Git hard to learn, but conversely it means that the content of a commit can be assembled independently of the working tree. It is this structure that lets git add -p stage only part of the changes to a single file.
4. The commit graph: ancestry and merge bases
Section titled “4. The commit graph: ancestry and merge bases”From now on we consider the graph obtained by keeping only the commit objects.
Definition 4.1(The commit graph and ancestry)
Let be the directed graph whose vertex set is all the commits of the repository and whose edges go from each commit to its parents. We call the commit graph. It is a finite DAG (by the same argument as at the beginning of the proof of Theorem 3.3).
For , write when there is a directed path from to (paths of length included), and say that is an ancestor of . When and we write .
A commit with and is a common ancestor of and ; the set of all of them is written . A maximal element of with respect to is called a merge base of and .
Lemma 4.2(Ancestry is a partial order)
The relation of Definition 4.1 is a partial order on : it is reflexive, transitive and antisymmetric.
Proof(Lemma 4.2)
Reflexivity. For any there is a path of length from to , so .
Transitivity. Suppose and . By definition there is a directed path from to and a directed path from to . The endpoint of and the starting point of are both , so they can be concatenated, giving a directed path from to . Hence .
Antisymmetry. Suppose and with . Concatenating a path from to with a path from to gives a path from to . Since , at least one of the two paths has length at least , so this is a cycle of length at least . That contradicts the fact that is a DAG (Definition 4.1). Hence .
Proposition 4.3(Existence of a merge base)
In a finite commit graph , let have at least one common ancestor, that is, . Then has at least one maximal element with respect to . In particular, if every commit of the repository is a descendant of a unique root commit (a commit with no parent), then any have a merge base.
Proof(Proposition 4.3)
The set is finite and, by hypothesis, non-empty. By Lemma 4.2, is a partial order on as well.
We construct a maximal element. Pick any . If is not maximal there is a with . If is not maximal there is a with , and iterating gives a chain . By transitivity for all , and by antisymmetry (if we would have , contradicting antisymmetry of ). So all terms of the chain are distinct, and finiteness of forces the chain to stop after finitely many steps. The term at which it stops is maximal.
For the last statement: if is an ancestor of every commit then , so and the first part applies.
Running git merge-base --all A B displays every merge base Git computed. In ordinary branch usage only one appears, but as we shall see, histories where this fails can be constructed.
5. Merging: 3-way merge, fast-forward and criss-cross
Section titled “5. Merging: 3-way merge, fast-forward and criss-cross”Once the merge base is found, Git compares three states: the base, our version and their version. This is the 3-way merge. We first define it in the abstract.
Definition 5.1(3-way merge)
Fix a finite set (the set of “positions”: lines, file paths, and so on) and a set (the values that can occupy a position), and call a map a version. Given a version (the base) and versions , put, for each ,
If for every , we say that and do not conflict relative to , and define the merge result by
If for some , we say that a conflict occurs at position .
This definition simply writes down the rule “take whichever of the two versions differs from the base; if two of them differ, hand the decision to a human”. Defining as a set spares us from worrying about overlapping cases and makes properties such as commutativity easy to see.
Proposition 5.2(Basic properties of the 3-way merge)
With the notation of Definition 5.1, the following hold for arbitrary versions .
- (Characterisation of conflicts) A conflict occurs at position if and only if , and .
- (Commutativity) If do not conflict relative to , then neither do , and .
- (The base is a unit) and never conflict relative to , and .
- (Idempotence) and never conflict relative to , and .
Proof(Proposition 5.2)
1. means that removing from the set leaves a two-element set. First, must itself have two elements, so ; second, nothing was removed, so and . Conversely, if these three conditions hold then has two elements.
2. The set appearing in the definition of is invariant under exchanging and . Hence so are , and the value .
3. Taking gives . If then and the definition gives . If then and . In either case , so there is no conflict, and the value equals . Since this holds for every , .
4. Here , so and no conflict occurs. If then and the value is ; if then and the value is . Hence .
Property 3 matters more than it looks. It says that if one side has changed nothing relative to the base, the merge simply adopts the other side and can never conflict. That is the theoretical content of fast-forward.
Proposition 5.3(Characterisation of fast-forward)
Let be the commit that HEAD currently points to and the commit being merged in, and assume . Then the following are equivalent.
- , that is, is an ancestor of .
- has exactly one maximal element, namely (the merge base is itself).
Moreover, in that case, performing a 3-way merge with the tree of as the base and the trees of and as the two versions produces no conflict and yields exactly the tree of . Hence Git need not create a new commit: it merely overwrites the value of the branch reference from to . This operation is called fast-forward.
Proof(Proposition 5.3)
1 implies 2. By reflexivity in Lemma 4.2 we have , and by hypothesis , so . Next, any satisfies by the definition of common ancestor. So is the greatest element of , and a greatest element is the unique maximal element. Indeed, if is maximal then , and would give , contradicting maximality of ; hence . And itself is maximal: if for some , then also , so antisymmetry gives .
2 implies 1. A maximal element belongs to , so , and the definition of common ancestor gives .
The second half. Regard a tree as a map from file paths to contents, taking to be the set of paths and the set of blob IDs (with a special value assigned to paths that do not exist). The base is the tree of itself, and the two versions are the tree of and the tree of . Applying property 3 of Proposition 5.2, with the base version being the tree of , one version being that same tree of and the other the tree of , we get no conflict and a result equal to the tree of . Since the resulting tree equals that of , and since makes a descendant of , there is nothing to be gained by creating a new commit with both and as parents. Advancing the reference to suffices.
Example 5.4(When a merge fast-forwards and when it does not)
Suppose feature was branched off main and two commits were made on feature alone. Writing for the commit that main points to and for the one feature points to, we have .
$ git switch main$ git merge featureUpdating a1b2c3d..d0e1f2aFast-forward src/auth.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+)Git reports Fast-forward and creates no merge commit; this is the situation of Proposition 5.3. The history becomes a single straight line, and no record remains that a branch called feature ever existed.
If, on the other hand, somebody else pushed one commit onto main in the meantime, then the commit that main points to is not an ancestor of . We have with maximal element , so condition 2 of Proposition 5.3 fails. A 3-way merge is then performed and a merge commit with two parents is created.
To create a merge commit even when a fast-forward would be possible, use git merge --no-ff feature. This keeps the extent of the branch in the history and makes git log --first-parent show what was integrated into main, feature by feature. Conversely, if the policy is to keep history linear, one can set git config --global pull.ff only so that anything that cannot fast-forward fails. Which to choose is a matter of team policy.
So far we have tacitly assumed that the merge base is uniquely determined. In general it is not.
flowchart RL C["C (merge of A and B)"] --> A["A"] C --> B["B"] D["D (merge of A and B)"] --> A D --> B A --> R["R (root commit)"] B --> R
Proposition 5.5(The merge result depends on the choice of merge base)
There exists a commit graph with two or more merge bases, together with an assignment of file contents to the commits, such that the 3-way merge does not conflict when one merge base is taken as the base but does conflict when the other is taken.
Proof(Proposition 5.5)
We use the criss-cross graph of the figure. First we compute . The ancestors of are and those of are , so . Since and , the commit is not maximal. The ancestors of are , which does not contain , and the ancestors of are , which does not contain ; hence and are incomparable under and both are maximal. So there are two merge bases, and .
Next we assign contents. Let the set of positions be (a file with a single line) and the set of values , and define the version of each commit as follows.
| Commit | Value | Explanation |
|---|---|---|
| the starting point | ||
| this line was changed from to | ||
| this line was untouched; only another file was changed | ||
| the merge of and was adopted as is | ||
| after merging and , this line was changed to |
Let us check that and are consistent. Merging and with base gives, by Definition 5.1, , so there is no conflict and the result is . The commit adopted this as is and so has value , while then edited the line and so has value ; both are commits that can genuinely be created.
Now merge and .
Taking (value ) as the base. Then , so , there is no conflict, and the result is .
Taking (value ) as the base. Then , so : the three conditions of property 1 of Proposition 5.2 (, , ) all hold and there is a conflict.
Thus the merge of the same two commits is either “no conflict, result ” or “a conflict”, depending on the choice of base.
Remark 5.6(How Git handles several merge bases)
As Proposition 5.5 shows, picking one of several merge bases is arbitrary. Git’s default merge strategy instead merges the merge bases with each other recursively to build a single virtual base, and performs the 3-way merge against that. In the example above, merging (value ) and (value ) with base (value ) yields a virtual base of value , and using it to merge and gives without a conflict.
This strategy was long called recursive, but since Git 2.34 (2021) the rewritten implementation ort (Ostensibly Recursive’s Twin) has been the default. The underlying idea is the same, with improvements in performance and rename detection. Passing git merge -s resolve switches to the older strategy, which merely picks one of the several merge bases, and the difference described above can then be observed.
Proposition 5.7(Rebasing always creates different commits)
Assume that the serialisation of a commit object is determined by the tree ID, the sequence of parent IDs, the author information, the committer information and the message, and that is injective on the set of serialisations under consideration (the same hypothesis as in Theorem 3.3). Let be the commit obtained from a commit by replacing its sequence of parents by and leaving everything else unchanged. If , or if for some , then .
Proof(Proposition 5.7)
By hypothesis, and differ in the part describing the parent IDs. The parent IDs appear in the body of a commit object as lines parent <ID>, in order, so if the number of such lines differs, and if the content of the -th line differs. Either way as byte strings.
Now argue by contraposition. If , that is , then injectivity of gives , contradicting what we have just shown. Hence .
git rebase takes each commit of some sequence, reapplies its changes (its difference from its parent) on top of a new foundation, and creates new commits. By Proposition 5.7, a commit after a rebase is a different object with a different ID from the original. The original commits do not disappear; they merely become unreachable from any reference (see the Appendix after Example 6.1).
6. Practice: the commands and the collaborative workflow
Section titled “6. Practice: the commands and the collaborative workflow”We now map the everyday commands onto the model built so far.
6.1. What is inside .git
Section titled “6.1. What is inside .git”.git/├── HEAD indirect reference to the current branch (e.g. ref: refs/heads/main)├── config configuration of this repository (remote URLs and so on)├── index the staging area; a draft of the tree the next commit will build├── objects/ the object store│ ├── d6/70460b... a loose object (one object, zlib-compressed)│ └── pack/ pack files (many objects delta-compressed and bundled)├── refs/│ ├── heads/main a branch; its content is one line holding a commit ID│ ├── tags/v1.0 a tag│ └── remotes/origin/main a remote-tracking branch└── logs/ the record of how references moved (the reflog)The contents can be inspected with git cat-file.
$ git cat-file -t HEAD # show the typecommit$ git cat-file -p HEAD # show the content, formattedtree 9f8e7d6c5b4a39281706f5e4d3c2b1a098765432parent a1b2c3d4e5f60718293a4b5c6d7e8f9012345678author Hanako Yamada <hanako@example.com> 1755907200 +0900committer Hanako Yamada <hanako@example.com> 1755907200 +0900
Add rate limiting to the login pathThis is exactly the structure tabulated in Definition 3.1. Following the tree line unfolds the file tree; following the parent line unfolds the history.
6.2. The main commands and their effect on objects and references
Section titled “6.2. The main commands and their effect on objects and references”| Command | What it means to the user | Effect on objects and references |
|---|---|---|
git add <path> | stage a change | write a blob of the content and update the corresponding index entry |
git commit -m "..." | record what was staged | build a tree from the index, write a commit whose parent is HEAD, and advance the current branch reference |
git switch -c <name> | create a branch and move to it | create refs/heads/<name> with the current commit ID and point HEAD at it |
git merge <branch> | integrate another branch | compute the merge base and perform either a fast-forward or a 3-way merge |
git rebase <base> | replant your commits on a new foundation | recreate the commits with new parents and point the branch reference at the new tip |
git fetch <remote> | obtain the remote’s history | bring in the missing objects and update refs/remotes/...; the working tree and your branches are untouched |
git pull | fetch and integrate | run git fetch followed by git merge (or git rebase, depending on configuration) |
git push <remote> <branch> | send your history | send objects and advance the remote reference; non-fast-forward updates are rejected by default |
It is worth remembering that git fetch changes nothing in the working tree. The procedure “first git fetch, then inspect the other side’s changes with git log --oneline HEAD..origin/main, and only then integrate” is always safe.
6.3. Collaboration based on pull requests
Section titled “6.3. Collaboration based on pull requests”Let us write out, as Git operations, the flow that has become standard on GitHub and GitLab. We describe the case of a member with write access creating a branch in the same repository (for an outside contributor the only difference is that a fork is created first).
- Update.
git switch main, thengit pull. This bringsmaininto line with the latest remote. - Create a working branch.
git switch -c feature/rate-limit. Choose a branch name that says what the work is. - Commit in small pieces. Use
git add -pto separate meaningful units and thengit commit. A good rule of thumb is that one commit should be a unit that can later be reverted on its own. - Publish.
git push -u origin feature/rate-limit. The-uoption sets the upstream so that plaingit pushsuffices afterwards. - Open a pull request (PR). Write what you changed and why. What a diff never tells you is the “why”.
- Pass the automated checks. CI runs the tests, the static analysis and the build. Fixing the CI environment with containers (Definition 3.1[Docker and Kubernetes]) is treated in Virtualisation technology (Docker and Kubernetes).
- Respond to review. Add further commits and
git push. The PR updates itself. - Take in changes to
main. Ifmainhas advanced during review, rungit fetchand thengit merge origin/main(or, depending on policy,git rebase origin/main) on the working branch. Resolve conflicts here. - Integrate. Merge the PR. GitHub offers three methods: creating a merge commit (which preserves the shape of the history), squashing into a single commit (which keeps
mainreadable), and rebasing the commits into line (which produces a linear history). - Clean up. Delete the working branch and run
git switch mainandgit pulllocally.
Example 6.1(Resolving a conflict)
Here is what actually happens when step 8 conflicts.
$ git fetch origin$ git merge origin/mainAuto-merging src/auth.pyCONFLICT (content): Merge conflict in src/auth.pyAutomatic merge failed; fix conflicts and then commit the result.Opening src/auth.py shows the markers Git wrote into it.
<<<<<<< HEADMAX_ATTEMPTS = 5=======MAX_ATTEMPTS = 3>>>>>>> origin/mainBetween <<<<<<< and ======= is our version; between ======= and >>>>>>> is theirs. This is the situation of property 1 of Proposition 5.2: a place where both sides changed the base value (say MAX_ATTEMPTS = 10) in different ways. To see all three versions, run
$ git checkout --conflict=diff3 src/auth.pyand the base content is displayed as well, separated by |||||||. Which one is right cannot be decided without knowing the intent behind both changes. That is why Git makes no automatic judgement and hands the decision to a human.
Once resolved, remove the markers and do the following.
$ git add src/auth.py # tell Git "this is how I resolved it"$ git merge --continue # create the merge commit (git commit does the same)To abandon the attempt, git merge --abort restores the state before the merge exactly. This works because objects are immutable (Definition 3.2), so restoring the original state is just a matter of moving references back.
7. Exercises
Section titled “7. Exercises”Exercise 7.1Easy
Determine the value printed by echo 'hello world' | git hash-object --stdin using the rule of Example 2.3. Note that echo appends one newline character. You may use Python’s hashlib for the computation. Also explain why this value is “the same in every repository and at every time”.
Solution
hello world is 11 bytes and the newline is 1 byte, so the content is 12 bytes. The object to be hashed is therefore b"blob 12\x00hello world\n".
import hashlibcontent = b"hello world\n"store = b"blob " + str(len(content)).encode() + b"\x00" + contentprint(hashlib.sha1(store).hexdigest())# => 3b18e512dba79e4c8300dd08aeb37f8e728b8dadThe value is universal because, in the content-addressing scheme of Definition 2.1, the key is determined by the content alone. A blob object contains no file name, no timestamp, no author and no repository identifier (check in the table of Definition 3.1 that it is the tree, not the blob, that carries file names). A file with the same content has the same ID in every repository in the world. This is also the reason git fetch can decide efficiently which objects the other side is missing.
Exercise 7.2Standard
Consider the following commit graph, with edges pointing from child to parent.
- the parent of is
- the parent of is
- the parent of is
- the parents of are and (a merge commit)
- the parents of are and (a merge commit)
Determine and list all of its maximal elements (the merge bases). Then, using Proposition 5.3, decide whether running git merge E while on D fast-forwards.
Solution
First compute the ancestor sets. Following parents from gives and , so the ancestors of are . From we get and , so the ancestors of are . Intersecting,
Now examine maximality. Since and , the commit is not maximal. The ancestors of are , which does not contain , so fails; the ancestors of are , which does not contain , so fails too. Thus and are incomparable and both maximal. There are two merge bases, and , and this history has the same shape as the criss-cross of Proposition 5.5.
As for fast-forward: by Proposition 5.3, a fast-forward happens exactly when the unique maximal element of is . Here the maximal elements are and — not , and there are two of them. So there is no fast-forward: a 3-way merge is performed and a merge commit with parents and is created (provided there is no conflict in the sense of Definition 5.1).
One can also check directly that is neither an ancestor nor a descendant of : the ancestor set of does not contain , and the ancestor set of does not contain .
Exercise 7.3Hard
Given a common base and three versions , compare computing and then with computing and then . Prove the following.
Putting for each position : in either order, a necessary and sufficient condition for no conflict to occur at any stage is that for every ; and when there is no conflict, the two final results agree.
In other words, as long as the base is held fixed, the 3-way merge is associative. Explain nevertheless why the order of merges can change the outcome in real Git, in the light of Proposition 5.5.
Solution
Fix a position and abbreviate , , , . By Definition 5.1, is if is empty, is the unique element if that set is a singleton, and is a conflict if it has two elements. This can be restated as follows.
Lemma. If , then is “the unique value among that differs from , or if there is no such value”, and in either case .
Indeed, if then and ; if then and .
Sufficiency, and agreement of the results. Assume for every . Since , we have , so is defined without conflict. By the lemma , and therefore
Since by hypothesis, the second stage does not conflict either, and the result is “the unique element of , or if ”. This expression is symmetric in , so the other order gives the same value.
Necessity. Suppose for some ; we show that a conflict occurs somewhere in this order. Since , two distinct values different from occur among .
- If has two elements, then the first stage conflicts at position .
- Otherwise , so is defined, and by the computation above . Since and , we get , so the second stage conflicts at position .
In either case there is a conflict. The same argument goes through verbatim after permuting , so the other order conflicts too. This establishes the equivalence.
Why the order matters in Git. What we proved is associativity conditional on holding the base fixed. Real Git does not hold the base fixed: at each merge it recomputes the merge base from the two commits at hand. Performing one merge creates a new merge commit and changes the shape of the commit graph, so the merge base of the next merge can be a different commit from before. Proposition 5.5 shows concretely that changing the base can change even whether a conflict occurs. Hence the phenomenon “I changed the order in which I integrated and it conflicted” comes not from any failure of associativity in the 3-way merge rule itself, but from the fact that the choice of base depends on the shape of the history.
Exercise 7.4Standard
You and a colleague are working on the same branch feature/x. You run git rebase main and then git push --force. What happens on your colleague’s machine the next time they run git pull? Explain using Proposition 5.7, and describe what kind of accident --force-with-lease prevents.
Solution
A rebase rewires the parent of each commit. By Proposition 5.7, a commit whose sequence of parents has changed necessarily has a different ID. So after the rebase the remote feature/x points at a different sequence of commits from the ones your colleague holds.
When your colleague runs git pull with the default configuration, git fetch is followed by git merge origin/feature/x. Writing for the old commit their local feature/x points at and for the new remote commit, does not hold (the ancestors of do not include ). By Proposition 5.3 there is therefore no fast-forward and a 3-way merge is performed. The result is that both lineages of commits representing the same changes remain in the history. Since the same change to the same lines appears twice, depending on the content this produces either a mass of conflicts or, worse, no conflict at all and a change applied twice. If your colleague then pushes, the old commits you thought you had removed come back to the remote.
The correct remedy is for your colleague to git fetch and then replant only their unpushed commits on the new foundation, for instance with git rebase --onto origin/feature/x <old fork point> feature/x. But the principle that avoids the trouble in the first place is not to rebase a shared branch at all.
What --force-with-lease prevents is a different accident. A plain --force overwrites the reference without checking the current remote value at all. If your colleague pushed a new commit between your git fetch and your push, that commit becomes unreachable from every reference and is effectively lost. --force-with-lease attaches the condition that the current remote value equal the value you last fetched. If the condition fails, the push fails, so you cannot unknowingly destroy someone else’s work. This is the same mechanism as a conditional update (compare-and-swap) in optimistic concurrency control.
References
Section titled “References”- Scott Chacon, Ben Straub, Pro Git, 2nd ed., Apress, 2014 — Chapter 3, “Git Branching”, and Chapter 10, “Git Internals”. The full text is freely available at https://git-scm.com/book/en/v2. The content of §3 of this article corresponds to Chapter 10.
- The official Git reference, https://git-scm.com/docs — in particular
git-merge-base,gitrevisions,githooks, and thehash-function-transitiondesign document. - Marc J. Rochkind, “The Source Code Control System”, IEEE Transactions on Software Engineering SE-1, no. 4 (1975), 364–370. DOI: 10.1109/TSE.1975.6312866 — the original SCCS paper, which formulates the problem of version control itself.
- Walter F. Tichy, “RCS — A System for Version Control”, Software: Practice and Experience 15, no. 7 (1985), 637–654. DOI: 10.1002/spe.4380150703 — history kept as reverse deltas, and the locking scheme.
- Sanjeev Khanna, Keshav Kunal, Benjamin C. Pierce, “A Formal Investigation of Diff3”, in FSTTCS 2007, Lecture Notes in Computer Science 4855, Springer, 2007, 485–496. DOI: 10.1007/978-3-540-77050-3_40 — a paper on the algebraic properties of the 3-way merge (diff3). The formalisation in §5 is a simplification of this line of argument.
- Marc Stevens, Elie Bursztein, Pierre Karpman, Ange Albertini, Yarik Markov, “The First Collision for Full SHA-1”, in CRYPTO 2017, Lecture Notes in Computer Science 10401, Springer, 2017, 570–596. DOI: 10.1007/978-3-319-63688-7_19 — the construction of the collision mentioned in Remark 2.4.
- Eugene W. Myers, “An O(ND) Difference Algorithm and Its Variations”, Algorithmica 1 (1986), 251–266. DOI: 10.1007/BF01840446 — the diff algorithm
git diffuses by default. It supplies the “matching up of positions” that precedes the 3-way merge.
Appendix: Recovering lost work
Section titled “Appendix: Recovering lost work”References move, but objects do not vanish. Whether you threw away commits with git reset --hard or replaced an old sequence of commits with git rebase, what moved — as Definition 3.2 says — was only a reference; the objects remain in the store. If you can find what remains, you can recover.
Look at the reflog first. In .git/logs/ Git records, for each reference, when it moved and from which value to which. This record is the reflog.
$ git reflogd0e1f2a HEAD@{0}: reset: moving to HEAD~39c8b7a6 HEAD@{1}: commit: Add logging for authentication errors5f4e3d2 HEAD@{2}: commit: Implement rate limitingNotation such as HEAD@{1} refers to a past position. To return to a discarded commit, run git reset --hard HEAD@{1}; to grow a branch from it, git switch -c rescue HEAD@{1}. The reflog is a local record and is not carried over to anyone who clones from you.
If it is not in the reflog either, use fsck. Objects reachable neither from any reference nor from the reflog can be listed with git fsck --lost-found. Lines reading dangling commit <ID> in the output are the orphaned commits. Inspect one with git show <ID> and, if needed, rescue it with git switch -c rescue <ID>.
But not indefinitely. git gc really does delete unreachable objects after a certain period. By default the reflog of reachable references expires after about 90 days and that of unreachable ones after about 30 days (configurable via gc.reflogExpire and gc.reflogExpireUnreachable). Start looking as soon as you notice an accident, and push important work early. In a distributed VCS the best backup is the existence of another clone. For the availability of the remote itself see Cloud computing (AWS, GCP) (availability is defined in Definition 6.1[クラウドコンピューティング], and the computation showing that more replicas raise availability is Proposition 6.2[クラウドコンピューティング]); for what git push actually says on the wire, see Networking (TCP/IP) (Definition 2.1[ネットワーク(TCP/IP)]).
Report an error in this article ・Operated by: Mugen Giken LLC ・Pricing ・Terms ・Legal notice
© 2026 夢現技研合同会社 ・Feeding the text to an LLM is welcome. Code samples are MIT licensed.