Koragraph

Git history and co-change

Git co-change is the fact that two pieces of code keep being edited in the same commits, which reveals coupling no parser can see because it lives in convention, not syntax.

Git co-change is the plain fact that two pieces of code keep getting edited together in the same commits. When two files change side by side again and again, they are coupled, even if nothing in the code says so. That coupling lives in habit and convention rather than syntax, which is why no parser can see it and why the history of a project is the only place it is written down.

What version control actually records

To see co-change you first have to see what a version control system stores. Version control is the tool teams use to track every change to a codebase over time. Git is the common one. The unit it records is a commit: a single saved change, bundling together the exact edits a developer made, with a message describing why, a timestamp, and the author’s name. A project’s history is just a long ordered list of these commits, thousands of them, each a snapshot of what changed and a note about the intent.

The detail that matters for co-change is that a commit almost never touches a single line in isolation. It touches a set of files together, because the developer was accomplishing one thing, and accomplishing that one thing required editing several places at once. The commit is a record of which places had to move together to make one coherent change. Multiply that by the whole history and you have thousands of small votes about which parts of the codebase belong to the same piece of work.

Mining the history

Mining git history means reading back through all those commits and counting patterns. The specific pattern for co-change is simple to state: for every pair of files, how often do they appear in the same commit. Walk the whole history, and for each commit note which files it touched, and tally every pair. Some pairs will co-occur once, by coincidence. Some will co-occur constantly, because every time one changes the other has to change too.

That tally is the raw signal. If file A and file B have shown up together in forty separate commits, that is not a fluke. Forty different times, forty different pieces of work, a developer decided that changing one meant changing the other. The history is telling you, in the plainest possible way, that these two files are bound together in practice.

Co-change as statistical coupling

The word for two things that must move together is coupling. Most discussions of coupling focus on the kind you can read in the code: A calls B, so A is coupled to B. Co-change is coupling of a different kind. It is statistical coupling, inferred not from any line of code but from the pattern of how the code was edited over time. You are not reading a relationship out of the syntax. You are observing a relationship in behavior and concluding the two things must be connected, because otherwise they would not keep moving in lockstep.

The everyday version is easy to picture. Imagine watching a busy kitchen over a month without ever being told how it works. You would notice that whenever the person at the grill got busy, the person plating desserts did not, but the person on sauces always did. You never read a rulebook. You inferred the dependency purely from watching what moved together. Git history lets you do the same to a codebase: infer the couplings from the pattern of activity, not from the recipe.

Why static analysis misses this

Static analysis is the practice of learning facts about a program by reading its structure: parsing the code and resolving that this function calls that one, this file imports that module, this type inherits from that one. It is precise and powerful for anything that is actually written in the code. Its blind spot is anything that is real but not written down, and there is more of that in a codebase than people expect.

Consider a few examples of couplings that leave no trace in the syntax. Two files may share an undocumented contract: one produces data in a particular arrangement and the other consumes it assuming that arrangement, but the agreement lives only in the developers’ heads, not in any shared type. Change one side and the other breaks, and no parser could have warned you, because to the parser the two files never referred to each other at all.

A common instance is a serialization format: the layout used to write data out to a file or send it over a network so that another program can read it back. The writer and the reader have to agree exactly on that layout, but the writer is often in one file and the reader in another, with nothing in the code linking them. They are bound as tightly as two functions in a call, yet static analysis sees two strangers.

Another is a magic constant: a bare value, a number or a string, that carries a meaning agreed on elsewhere. If one file uses the status code seven to mean “refunded” and a distant file also treats seven as “refunded,” the two are coupled through that shared meaning. Change the number in one place and the other silently disagrees. Nothing in the syntax connects a seven in one file to a seven in another. But in the history, the two files were edited together every time the meaning of seven changed, and there the coupling is plain.

Some of the tightest couplings in a codebase are never written in the code. They live in convention, and the only place convention is recorded is the history of what changed together.

The “changed together forty times” example

Make it concrete. Two files sit in different corners of a project. Read either one and you find no mention of the other: no import, no call, no shared type, nothing a static tool could latch onto. By every structural measure they are unrelated. Yet the history says they have been changed together in the same commit forty times.

Forty is not noise. Forty is a pattern that demands an explanation, and the explanation is almost always that there is a real dependency between them that is simply not expressed in the code. Perhaps one writes a data format the other reads. Perhaps both encode the same business rule and have to agree. Perhaps they implement two ends of a handshake. Whatever it is, a developer who edits one of these files and not the other is, forty commits of evidence say, probably about to introduce a bug. That warning is available nowhere except in the history, and it is exactly the warning you want before you touch the file.

Noise, and how to weight it

Co-change is a strong signal, but a raw count is a crude one, and treating every co-occurrence as equal would mislead you. The honest version of the technique has to account for noise.

The biggest source of noise is the large commit. Sometimes a single commit touches two hundred files at once: a mass rename, a formatting pass, a bulk update across the whole project. In a commit like that, every pair of files “changed together,” but none of it means anything about coupling. It means one mechanical operation swept through everything. A commit that touches two files together is strong evidence they are related; a commit that touches two hundred is almost no evidence about any particular pair. So co-occurrences from small, focused commits should count for far more than co-occurrences from sprawling ones.

There are other adjustments worth making. Two files that each change constantly will co-occur often just by volume, so it is more telling to ask whether they change together more than their individual activity would predict by chance, rather than looking at the raw count alone. Recent history often matters more than ancient history, since a coupling from five years ago may have been refactored away. The point is not a single formula but a posture: co-change is a real signal that has to be weighted, not a raw tally to be trusted blindly. Do the weighting and you get a ranking of couplings that reflects genuine hidden dependence. Skip it and you drown the real signal in the noise of a few giant commits.

Reading it as a warning, not a rule

It helps to be clear about what a co-change edge does and does not claim. It does not say the two files must always change together, and it does not explain why they are coupled. It says something narrower and still useful: historically, editing one without the other has been rare, so if you are about to do exactly that, stop and check that you are not missing a hidden obligation. It is a prompt to look, not a verdict.

Read that way, co-change fills a gap that nothing else covers. When a new engineer edits a file, they see its imports and its callers, but they cannot see the file across the project that has quietly moved with it for two years. A co-change edge surfaces that invisible partner at exactly the moment it matters, before the edit ships. For an AI coding agent working without any of a veteran’s accumulated memory, that surfacing is even more valuable, because the agent has no folklore to fall back on and would otherwise treat the two files as complete strangers.

A real edge, invisible to static analysis

The important claim to hold onto is that co-change is not a fuzzy heuristic bolted onto the side of the real analysis. It is a genuine relationship, a real edge, that static analysis cannot produce by construction, because it comes from a different source of truth entirely. Static edges come from what the code says. Co-change edges come from what the developers did. Both are facts about the system. Neither can be derived from the other. A complete picture of how a codebase is connected needs both, and a picture built from syntax alone will always be missing the couplings that live only in convention.

Koragraph treats this as a first-class part of its map. It mines git history and resolves git co-change as one of the edge types it records, alongside calls, imports, inheritance, and cross-service links. Those facts are stored in a local database that persists across sessions and follows files even when they are renamed, which matters for history in particular: because renames are tracked, the co-change record for a file is not thrown away the moment someone moves or renames it. The history stays attached to the code it describes.

It is worth saying plainly why this source of truth is so durable. Code can lie about its intentions; comments go stale, names drift from what they do, and a shared assumption between two files is written nowhere. But the history cannot be edited after the fact in the same way, because it is a record of what actually happened, commit by commit. Every time two files moved together, a real developer made a real decision that they had to. The pattern is not a description someone wrote and forgot to update. It is the accumulated evidence of the work itself, which is exactly why it can reveal couplings the current code no longer admits to.

Where this connects

Co-change is one edge type within the code knowledge graph, the explicit map of how a codebase is connected. It is the edge that captures relationships syntax cannot, and it is what keeps that map honest about the couplings that only ever lived in how the code was worked on.

It feeds directly into blast radius, the question of everything that could break if you change one piece of code, because a file that has changed with yours forty times belongs in the set of things to check even when no call or import connects them. Producing it is the job of the ingest pipeline, the process that reads repositories, including their full history, and turns what it finds into stored edges. And because it is a fact that persists across sessions and survives renames, it is close kin to memory for agents, the idea that useful knowledge about a codebase should be written down once and recalled later rather than rediscovered on every task. Follow any of those, and the reason a project’s history deserves a place on the map comes into focus.

Connected concepts

The code knowledge graphA code knowledge graph is a structured map of a codebase where every declaration is a node and every real relationship, calls, imports, inheritance, cross-service links, is an edge.Blast radiusBlast radius is everything that could break if you change one piece of code, including the paths that cross a repository boundary and the ones no test covers.The ingest pipelineIngest is the pass that turns a pile of repositories into one graph: parse with tree-sitter, extract declarations, resolve every edge, mine the git history, and write it all to the local store.Memory for agentsMemory is what lets an agent keep a fact past the end of a conversation, so a lesson learned once, a fix that worked, a hazard, does not have to be rediscovered on every session.

Where this sits

Back to the full graphThe short glossary