• Terms of Service

Learn Internet Grow

Tech Skills

  • Learn
  • Books
  • Search
  • Create
  • Measure
  • Secure
  • Share
  • Terms of Service

Your git log — file Is Lying to You: Git History Simplification Explained

Filed Under: Search

Run git log -- src/auth.py and get back three commits over two years. A reasonable engineer concludes the file barely changed. That conclusion can be flatly wrong, and Git will not warn you — because git log filtered before it summarized, and the filter it applies by default, called Git history simplification, is allowed to skip commits that genuinely touched the file. Not hide them behind a “load more” button — skip them, never walk them, never mention they exist. A query engine with a helpful default is a hidden filter until you know exactly what it throws away.

Git history simplification — a branching commit graph where one parent's line at a merge stays lit and traced, and the other fades into unvisited fog

The Query You Didn’t Know You Were Running

Every git log invocation with a pathspec is running a filter, whether you asked for one or not. Git’s own documentation states plainly what the default optimizes for: it “simplifies the history to the simplest history explaining the final state of the tree,” pruning “side branches if the end result is the same” — the goal is a plausible story of the tree, not a complete one. It’s a summarization algorithm wearing a search command’s clothes.

What TREESAME Actually Means

The mechanism has a name: TREESAME. A commit is TREESAME to a parent when the pathspec you gave resolves to identical tree contents in both the commit and that parent. Git’s algorithm, quoted from git-log, runs in two steps easy to conflate but doing different jobs:

  1. Deciding what to walk. “If the commit was a merge, and it was TREESAME to one parent, follow only that parent. (Even if there are several TREESAME parents, follow only one of them.) Otherwise, follow all parents.”
  2. Deciding what to show. “Commits are included if they are not TREESAME to any parent.”

Step 1 is where the real damage happens, and it’s the step almost nobody reads carefully. At a merge, if the merged-in tree state for your path matches one parent exactly, Git doesn’t just decline to display the other parent’s history for that path — it never traverses it. A commit three generations up the discarded parent, one that legitimately modified src/auth.py before that branch merged, was never a candidate. git log -- path isn’t hiding an answer; for that commit, it never asked the question.

Step 2 is the familiar half — the part everyone assumes is the whole story: among whatever got walked, only show commits that changed something. That part is genuinely just readability. It’s step 1, the merge-following rule, that turns “I don’t want noise” into “I might not see real history.”

TREESAME history simplification at a merge — a beam of light follows one parent thread while the other continues unlit and untouched

That’s the failure mode that makes git log -- path dangerous for anything forensic: a security review reconstructing every commit that touched an authentication file, a compliance audit needing a complete change list, a regression investigation enumerating every candidate. Each needs the complete set of commits that touched a path, and the default query engine is explicitly optimized not to give you that.

The Escape Hatches: --full-history, --simplify-merges, --sparse, --first-parent

Git ships four ways to change what gets walked and shown, and they are not interchangeable — each answers a different question.

ModeWhat Git walksWhat Git showsUse it when
Default (pathspec, no flags)Only the TREESAME-matching parent at each merge — other parents are never visitedOnly !TREESAME commits along the walked pathYou want a short, readable “what changed” summary and can tolerate incompleteness
--full-historyAll parents at every merge, no pruning of which lines get visited!TREESAME ordinary commits; merges are always kept, with their parent lists rewritten to only included ancestorsYou need a complete commit set for a path — the correctness-first option
--full-history --simplify-mergesSame full walk as --full-historyThe full-history result, then strips merges that end up contributing nothing new once the walk is rewrittenYou want completeness without the noisy pile of no-op merge commits --full-history alone tends to produce
--sparseSame restricted, single-parent walk as the defaultEvery commit along that walk, TREESAME or not — nothing hidden within the path actually visitedYou want to see the full shape of the walked line, but haven’t fixed the underlying visibility gap
--first-parentOnly ever the first parent at any merge, regardless of TREESAME statusOrdinary first-parent-line commitsMerge-heavy repos where you want “what landed on this branch,” not every topic-branch detail

Git’s own wording for --full-history is a near-trap in itself: documented as “same as the default mode, but does not prune some history” — easy to skim past, but “does not prune” is exactly the guarantee a forensic query needs. Merges are always included, and their parent lists rewritten so each parent shows only the commits that survived the wider walk. --simplify-merges is framed as an “additional option to --full-history” — it doesn’t replace the full walk, it cleans up after one, dropping merges that “contribute” no selected commits once the dust settles. Order matters: nothing to simplify until the wider walk has already happened, and it’s a strictly more expensive computation — Git builds the entire history graph before deciding what’s safe to discard.

Why --sparse Doesn’t Solve the Real Problem

--sparse is the escape hatch most likely to be reached for and least likely to fix what you actually wanted. Its documented behavior is narrow: “all commits in the simplified history are shown” — it removes the second filter (TREESAME-only display) but leaves the first (the single-parent walk at merges) completely intact. If a merge was TREESAME to one parent, --sparse still only walks that parent; it just stops hiding the TREESAME commits along the way. The commit sitting on the discarded branch is exactly as invisible under --sparse as under the plain default. For “show me every commit that ever touched this path,” --sparse is not that flag — --full-history is.

The same Git history seen through different simplification filters — default, full-history, simplify-merges, and first-parent scans of one graph

--first-parent solves a different problem on purpose. Restricting the walk to first parents “can give a better overview when viewing the evolution of a particular topic branch, because merges into a topic branch tend to be only about adjusting to updated upstream from time to time,” in Git’s own framing — it treats each merge as one opaque unit of “this feature landed” rather than surfacing every intermediate commit on the contributor’s own branch. In a merge-heavy trunk with dozens of short-lived feature branches, that’s often the more useful lens on “what actually happened here” — the difference from the default is that this omission is a documented, intentional choice about which level of history you’re asking for, not a side effect of how a pathspec meets a merge. A related flag, --show-pulls, keeps default behavior but also surfaces the merge that first introduced a change — useful when “which merge brought this in” is the actual question.

git log as a Query Engine: Traversal, Ordering, and Ancestry

Once you stop treating git log as “print the commits” and start treating it as a query planner over a DAG, its ordering flags read differently. By default, Git states plainly, “the commits are shown in reverse chronological order” — sorted by commit timestamp, full stop. That default carries an assumption worth naming: commit timestamps are author-supplied metadata, not a guarantee of causal order. A rebase, a cherry-pick, or a machine with a wrong clock can produce a commit whose timestamp doesn’t match when it was actually integrated, and the reverse-chronological default has no mechanism to notice.

--topo-order fixes the causal half of that problem: it guarantees Git will “show no parents before all of its children are shown, and avoid showing commits on multiple lines of history intermixed” — concurrent branches print as coherent runs instead of shuffled together by clock time. It’s not an opt-in oddity, either: passing --graph implicitly turns it on (“this implies the --topo-order option by default”), which is why a git log --graph you never explicitly ordered still reads as a sane, non-interleaved tree.

--date-order keeps the same “no parent before its children” guarantee but breaks ties by raw timestamp rather than branch structure — concurrent lines can interleave in a way that looks arbitrary, because the sort key is still a clock, not causality.

--ancestry-path: Filtering a Range to What Actually Descends

A rev1..rev2 range already restricts you to commits reachable from rev2 but not rev1 — but that range can include commits from unrelated concurrent work reachable in the same window without being ancestors or descendants of any commit you actually care about. --ancestry-path[=<commit>] narrows further: given that range and a <commit> inside it, Git will “only display commits in that range that are ancestors of <commit>, descendants of <commit>, or <commit> itself” — per git-log, repeatable, keeping a commit if it relates that way to any named commit. It’s “trace this exact line of descent through a busy range,” not “show me everything in this window.”

git log --ancestry-path — a glowing corridor isolating one true line of descent within a busy range of concurrent commits
Topological order versus date order in git log — one braided history flowing cleanly, the other kinked out of causal sequence

Adjacent to ordering is a sharp syntax trap worth knowing before it costs a debugging session. gitrevisions defines two operators that look interchangeable and are not: <rev>^<n> selects a specific numbered parent of a merge, while <rev>~<n> walks n generations back through first-parent history only. HEAD~3 and HEAD^^^ (HEAD^1^1^1) are the same commit — but HEAD^2 (the merge’s second parent) and HEAD~2 (two generations along the first parent) are almost never the same commit once a merge sits in between. Scripts that assume the two are interchangeable work on linear history and silently pick the wrong commit the moment a merge appears — a syntactic cousin of the same “the DAG isn’t a line” lesson --ancestry-path and history simplification teach at a larger scale, the same graph-theory intuition underneath both.

Why These Walks Stay Cheap: Generation Numbers

None of the traversal above is free at scale. Naively answering “is commit A an ancestor of commit B” means walking the graph with no shortcut — expensive fast on a large, long-lived repository. Git’s commit-graph file precomputes a generation number for every commit, guaranteed higher than any of its ancestors, so an ancestry or ordering query can stop the instant it proves the answer instead of walking the rest of the graph. It’s the same move as a database index: precompute a monotonic property once, and a full scan becomes an early exit — GitHub’s own monorepo engineering names exactly this class of reachability walk as what makes or breaks interactive performance at scale.

The Pickaxe: -S vs -G vs --find-object vs -L

History simplification governs which commits a pathspec query considers. A separate family of flags governs which commits changed something specific — here the confusion isn’t a hidden default, it’s near-identical syntax hiding genuinely different questions.

ToolAnswersTriggers onCommon mistake
-S<string> (pickaxe)“When did the number of times this string appears change?”An addition or deletion that changes the occurrence count of the string in the fileAssuming it catches every commit that touched matching text — it doesn’t, if the count stayed the same
-G<regex>“When did a line matching this pattern get added or removed?”Any diff hunk containing an added or removed line matching the regex, regardless of countAssuming -S and -G are interchangeable spellings of “find this text” — they answer different questions
--find-object=<oid>“When did this exact blob (or submodule commit) start or stop being referenced?”Occurrence-count logic identical to -S, but keyed to an object id, not a stringForgetting it survives renames that string search can’t — object identity doesn’t care what the file is called
git log -L<range>:<file>“How did this specific range of lines (or this function) change over time?”Not a commit filter at all — a per-line/-function walk of the diff hunks touching that rangeTreating it as a faster -S/-G; it’s a different tool that happens to accept the same pickaxe flags as extra filters

-S and -G are documented side by side in git-diff — they look like two spellings of one feature and answer genuinely different questions. Git’s own disambiguating example, quoted directly: a commit changes

-    hit = frotz(nitfol, mf2.ptr, 1, 0);+    return frotz(nitfol, two->ptr, 1, 0);

The string frotz(nitfol appears exactly once before this change and exactly once after — the count never moved. git log -S"frotz\(nitfol" --pickaxe-regex will not show this commit, because -S fires strictly on occurrence-count changes. git log -G"frotz\(nitfol" will show it, because a matching line was both removed and added — content changed, even though the match count stayed identical. -G is closer to what most people mean by “when did this get touched”; -S is the precision instrument for “when did this exact construct appear or disappear,” and Git’s own docs describe feeding a -S result back into -S iteratively as the technique for walking code back to the commit that first introduced it.

Pickaxe search compared — a steady occurrence count beside a searchlight catching a change in content itself

--find-object=<object-id> reuses -S‘s occurrence-counting logic but keys it to a specific blob or submodule commit id instead of a text pattern — per git-diff, it “implies the -t option in git-log to also find trees.” That matters where string search structurally can’t help: a renamed file carries no textual thread for -S/-G to follow, but its content still hashes to the same blob id before and after the rename, so --find-object finds it anyway — a search keyed to identity, the same content-addressing property that makes Git’s object store work at all, doubling as a query capability.

-L: A Different Kind of Query Entirely

git log -L<start>,<end>:<file> (or -L:<funcname>:<file> for a function-name regex) isn’t a commit filter — it’s a per-range history viewer. Instead of a commit list, it walks the diff hunks that touched a line range or function over time, using the same funcname-header heuristic git diff uses to label hunks. Git’s docs note a real constraint: it’s “currently limited to a walk starting from a single revision,” zero or one positive revision arguments — unlike -S/-G, it can’t be pointed at an arbitrary multi-branch range. It composes with the pickaxe options, so -L and -G can stack when the question is “this function’s history, but only where this pattern also changed.” That’s the same instinct behind treating code-reading as its own skill — -L narrates one function across years of commits in a way a commit-level pickaxe search can only point toward.

Commit-graph generation numbers as an early exit — a short direct beam bypassing a long tangled history walk

The Lesson: A Helpful Default Is a Hidden Filter

Step back from the flags and a pattern runs through all of it: git log is a query engine, and every query engine decides what “the answer” means before it returns one. Git’s designers made a defensible choice — optimize the default for a short, readable story of how a file got to its current state — and documented, honestly, that the story is allowed to be incomplete. The failure isn’t Git’s choice; it’s every engineer who runs git log -- path, gets a short answer, and reads “short” as “complete.”

That’s a transferable lesson well past Git. Anywhere a system silently prunes, dedupes, or “simplifies” results for readability — a log aggregator collapsing repeated events, a search index deduping near-identical documents, an observability tool sampling traces — the same question applies: what did the default actually promise, and did you verify that before trusting a short result as a negative? A query language with a helpful default is a query language with a hidden filter, and the only defense is reading what the filter does before trusting what it doesn’t show you. It’s the same discipline that matters when trusting a database’s own default query behavior instead of verifying what an index or planner actually did — and the same blind spot mutation testing exists to catch in test suites: code that looks covered because nothing announced what the coverage tool quietly skipped.

It has a sharp, practical edge for anyone mining Git history for numbers rather than commits: a script counting commits touching a path for a changelog, an ownership report, or worse, a productivity metric, built on a default git log -- path invocation, already reflects an undisclosed filter — a real number, honestly computed, over a smaller and non-obvious population than the one you assumed you were measuring.

  • Before trusting a short git log -- path result, ask which parent Git followed at every merge — not just whether it hid TREESAME commits, but whether it ever visited the other side.
  • Reach for --full-history, not --sparse, when the requirement is “every commit that touched this path” — --sparse only unhides commits on the path already walked.
  • Pair --full-history with --simplify-merges once the raw output is too noisy with no-op merges — it costs more, since Git builds the full graph before it can simplify.
  • Know which pickaxe question you’re asking: -S for “did the count change,” -G for “did the content change,” --find-object for “did this blob’s presence change” — and reach for -L when the question is a line range’s story, not a commit list.
  • Don’t build metrics on a default git log -- path count without checking whether history simplification already thinned the population you’re measuring.

📚 Further Reading

  • git-log — history simplification, TREESAME, --full-history, --simplify-merges, --sparse, --show-pulls, --first-parent, --topo-order, --date-order, --ancestry-path, -L.
  • git-diff — -S/-G pickaxe semantics, --pickaxe-regex, --find-object, the frotz(nitfol example quoted above.
  • gitdiffcore — the pickaxe mechanism git-diff refers out to.
  • gitrevisions — full revision/range syntax, including ^<n> vs ~<n>.
  • git-rev-list — the plumbing underneath git log‘s traversal and ordering.
  • commit-graph — generation numbers and the reachability invariant behind cheap large-scale walks.
  • GitHub Engineering — “Scaling monorepo maintenance” — why fast ancestry/reachability walks matter once history gets large.
  • Wikipedia — Directed acyclic graph — the structural definition behind everything git log walks.
  • Learning the Brilliance of Graph Theory — the DAG intuition behind ancestry, ordering, and ranges.
  • Reading Code Is the New Superpower Skill for Engineers — what to do once a pickaxe or -L query hands you the commit.
  • Postgres as a Vector Database: What Benchmarks Say — the same “verify the default” discipline, different query engine.
  • Mutation Testing for AI-Generated Code (Beyond Coverage) — the test-suite version of “what looks complete may not be.”
  • DORA Metrics for AI Coding Agents: Stop the Gaming — why commit-mining on an unexamined git log default is a measurement risk.

❓ Frequently Asked Questions

What does “TREESAME” mean in Git?

A commit is TREESAME to a parent, for a given pathspec, when the tree contents at that path are identical between the commit and that parent — nothing changed for the path you asked about. By default, git log -- path shows only commits not TREESAME to any parent, and at a merge TREESAME to one parent, it follows only that parent — the other parent’s line is never walked.

Why does git log -- file show fewer commits than I expect?

Because the default pathspec query optimizes for a short, readable story of how the file reached its current state, not a complete audit trail. At a merge, if one parent’s tree already matches the merge result for that path, Git follows only that parent — a commit that touched the file solely on the discarded side is never visited, not just hidden.

What’s the difference between --full-history and --full-history --simplify-merges?

--full-history alone walks every parent at every merge and includes every !TREESAME commit plus all merges — complete but often noisy. Adding --simplify-merges performs that same full walk first, then removes merges that, after rewriting, contribute no selected commits — completeness with less clutter, at a higher computational cost.

Does --sparse fix the missing-commits problem?

No. --sparse only changes what gets displayed along the path Git already walks — it shows TREESAME commits instead of hiding them. It doesn’t change which parent gets followed at a merge, so a commit living entirely on a discarded parent branch stays just as invisible as it is by default. --full-history is the flag that actually walks every parent.

When should I use -S instead of -G for the pickaxe search?

Use -S<string> when you care whether the number of occurrences of a string changed — for example, tracking a block of code back to its first appearance by feeding results back into -S iteratively. Use -G<regex> for any commit where a matching line was added or removed, regardless of count — closer to what most people mean by “when did this get touched.”

What’s the difference between git log -L and the pickaxe (-S/-G)?

The pickaxe filters which commits show up in a normal commit list, based on string or content changes. git log -L<range>:<file> is a different tool — it returns no commit list, it walks the diff hunks touching a line range or function across history and shows how that range evolved. The two combine, since -L accepts pickaxe options as extra filters.


📚 Recommended Resources

Disclosure: some links below are Amazon affiliate links. If you buy through them, I earn a small commission — at no extra cost to you — which helps keep this site running.

Querying history well means understanding what git log is actually built on top of — the object model and the plumbing commands doing the walking underneath the porcelain. These go deeper on both halves.

  1. Pro Git (2nd ed.) — Scott Chacon & Ben Straub · Book. Also free in full at git-scm.com/book under CC BY-NC-SA 3.0 — read it there first. Chapter 10, “Git Internals,” is the closest match to this article’s register: the object model and revision-walking machinery that git log‘s simplification and ordering flags are built on top of.
  1. Version Control with Git (3rd ed.) — Prem Kumar Ponuthorai & Jon Loeliger · Book. The deepest print treatment of Git’s full command surface, including the history-simplification and pickaxe behavior this article covers, plus the plumbing layer underneath both.
  1. Working Effectively with Legacy Code — Michael Feathers · Book. The canonical text on the exact situation that makes accurate history queries matter most — reconstructing why unfamiliar, undertested code ended up the way it did, which is precisely what a -G, -S, or -L search is for.

For the traversal and simplification algorithms themselves rather than the porcelain wrapped around them, James Coglan’s Building Git rebuilds Git’s internals from scratch in Ruby, chapter by chapter, including the revision-walking and diff machinery this article describes. No Amazon listing exists for it — it’s sold ebook-only, direct from the author. If that angle interests you, it’s worth buying directly from shop.jcoglan.com/building-git — a non-Amazon purchase, no affiliate link here.

Newsletter

Tags

Agentic AI Agentic Development AI AI Agents AI Coding Agents Artificial intelligence Backlinks Blockchain Bounded Context Browser ChatGPT Plugins Claude Code CLPR Context Engineering Cross-Chain Cryptocurrency Developer Tools Distributed Systems DLT Domain-Driven Design Email Google Search HBAR Hedera Hedera Hashgraph Internet LLM Machine Learning MCP Microservices Model Context Protocol News NLP Orchestration Photoshop Productivity Saga Pattern SEO Smart Contracts Software Architecture software engineering Twitter Typescript Web3 Writing

Recent Articles

  • QUIC and HTTP/3: The Protocol That Won Is Being Routed Around October 11, 2026
  • The Mark I Perceptron: The Machine That Learned to See October 11, 2026
  • GraphRAG vs Vector RAG: Knowledge Graphs for AI, Explained October 11, 2026
  • Cardano Tokenomics: How ADA’s Supply Actually Works October 10, 2026
  • Transformer Encoder vs Decoder: How to Read the Diagram October 10, 2026
  • Nobody Checks the Verified Badge: The Real State of Git Commit Signing October 10, 2026

Copyright © 2026 · News Pro Theme On Genesis Framework · WordPress · Log in

Content on this site is produced with the assistance of Large Language Models and edited by a human. It is intended for educational purposes — facts may occasionally be incorrect. If you spot an error, please email Larionovdmitri@learninternetgrow.com.

Affiliate Disclosure: This site contains affiliate links to Amazon and other partners. If you purchase through these links, LearnInternetGrow.com may earn a commission at no additional cost to you. We only recommend products we genuinely believe in.

Privacy Policy · Terms of Service