Docs
View as markdown

Import git history with git-to-graph

Turn a git checkout's history into a graph of people, commits, and files that change together, using the first-party git-to-graph extension.

extensionsclicypher

Before you start: Install and run an extension

This guide shows you how to import a git repository's history into a store A relate database: a directory, a single .rl file, or :memory:, opened with --database. Read more in the glossary with git-to-graph, a first-party extension, and which queries to run on it. Each author becomes a :Person, each commit a :Commit, and each path a commit touched a :File. A CO_CHANGED relationship between two files counts the commits that changed both, which is what answers "what else changes when this file does".

Prerequisites

  • relate on your PATH (Install relate).
  • git on your PATH. The extension reads the history with git log.
  • git-to-graph installed. It is on the first-party channel, and it is a self-contained binary (Install and run an extension):
relate extension install git-to-graph

Import a repository

The examples import planner/, a repository with five commits by three people. Grace committed once as [email protected]:

CommitAuthorSubjectFiles
a6beb54AdaStart the plannerREADME.md, src/main.rs
e39b224GraceAdd the plan modulesrc/main.rs, src/plan.rs
328e70dAdaFix the cost estimatedocs/costs.md, src/plan.rs
4d13800GraceCall the planner from mainsrc/main.rs, src/plan.rs
6e62425NoorFix README wordingREADME.md

The run writes into the store named by -d, a new code.rl in this example:

relate run git-to-graph planner -d ./code.rl
imported 5 commits, 3 people, 4 files (9 TOUCHED, 3 CO_CHANGED edges) into the store — try:
  MATCH (a:File)-[r:CO_CHANGED]->(b:File) RETURN a.path AS file, b.path AS co_changed, r.count AS times ORDER BY times DESC LIMIT 10;
  MATCH (p:Person)-[:AUTHORED]->(c:Commit) RETURN p.name AS person, count(c) AS commits ORDER BY commits DESC LIMIT 10;
  MATCH (c:Commit) SEARCH c IN (FULLTEXT INDEX commit_subject FOR 'fix' LIMIT 10) RETURN c.sha AS sha, c.subject AS subject, c.authored_at AS authored_at;
  See references/run-git-to-graph.md for more questions; run `relate skill install` to add it to your agent.

The last line points at the question catalog in relate's skill, published at /skills/relate/references/run-git-to-graph.md: 30 queries for coupling, hotspots, ownership, reviewers, and time.

The flags come from the extension's manifest, plus relate's own -d:

FlagDefaultMeaning
DIRthe current directoryThe checkout to read; the history is HEAD's
--max-commits N0Read at most N commits, newest first; 0 reads every commit
--since VALUEemptyPass --since=VALUE to git log, a lower bound on the committer date; empty means no bound
--max-changeset N50A commit that touched more than N files adds no CO_CHANGED pair; accepts 1 to 1000
-d, --database PATH:memory:The store to import into; relate opens it, not the extension

The graph it builds

Data model: people author commits, commits touch files, files change togetherA Person AUTHORED each Commit they wrote, and a Commit TOUCHED every File it changed. Two files that changed in the same commits are joined by CO_CHANGED, whose count is the number of those commits.PersonemailnameCommitsha · subjectauthored_atfiles_changedFilepath · nameAUTHOREDTOUCHEDCO_CHANGEDcount
Data model: people author commits, commits touch files, files change togetherA Person AUTHORED each Commit they wrote, and a Commit TOUCHED every File it changed. Two files that changed in the same commits are joined by CO_CHANGED, whose count is the number of those commits.PersonemailnameCommitsha · subjectauthored_atfiles_changedFilepath · nameAUTHOREDTOUCHEDCO_CHANGEDcount
Data model: people author commits, commits touch files, files change together

In words: A Person AUTHORED each Commit they wrote, and a Commit TOUCHED every File it changed. Two files that changed in the same commits are joined by CO_CHANGED, whose count is the number of those commits.

Each label A name that classifies a node, such as Person; a node has zero or more, and patterns, indexes and constraints refer to them. Read more in the glossary and relationship type The single name every relationship carries, such as KNOWS or WORKS_ON; patterns match on it, and a relationship cannot change it. Read more in the glossary carries these properties:

ElementProperties
:Personemail (lowercased; the key), name (from that person's newest imported commit)
:Commitsha (the key), subject, authored_at (a datetime, the author date), files_changed
:Filepath (the key), name
AUTHOREDNone; from a person to each commit they wrote
TOUCHEDNone; from a commit to each file it changed
CO_CHANGEDcount: the imported commits that changed both files; from the alphabetically smaller path to the larger

The extension creates four indexes. Range indexes file_path, commit_sha, and person_email are created before the first write, and the full-text index An index over one or more string properties that finds entities containing given words, ranked by a BM25 relevance score. Read more in the glossary commit_subject on Commit.subject after the last.

These rules shape the graph:

  • Merge commits are left out, and only HEAD's history is read: branches, tags, and the commit graph itself are not imported.
  • A :File is every path a commit touched, including files deleted since. path is DIR joined with the path git prints, so planner gives planner/src/main.rs, the same key fs-to-graph writes for the same DIR.
  • Each batch of rows commits on its own. A failed run can leave its earlier batches committed; running the same command again completes the import, because every write is a MERGE and count is overwritten, never added to.
  • A narrower run, with --since or --max-commits, overwrites only the pairs it recounts. Import into a new store when the window changes.
  • An import refuses before it writes anything when git log prints more than 134217728 bytes (128 MiB), or when it would write more than 5000000 CO_CHANGED pairs.

Query the graph

The cells in this section run in your browser. The first two write the graph and the indexes the run built for planner/, so the queries return what they return on code.rl:

Cypher
CREATE (ada:Person {email: '[email protected]', name: 'Ada'}),
       (grace:Person {email: '[email protected]', name: 'Grace'}),
       (noor:Person {email: '[email protected]', name: 'Noor'}),
       (c1:Commit {sha: 'a6beb5489a80657274dbb570b5c077d83d4539ae', subject: 'Start the planner',
                   authored_at: datetime('2026-10-01T09:00:00Z'), files_changed: 2}),
       (c2:Commit {sha: 'e39b224583a535bbc3dc4b7bec3378847e1eae13', subject: 'Add the plan module',
                   authored_at: datetime('2026-10-02T10:30:00Z'), files_changed: 2}),
       (c3:Commit {sha: '328e70d49ae4b6636117d5651f41cb941048440b', subject: 'Fix the cost estimate',
                   authored_at: datetime('2026-10-05T14:00:00Z'), files_changed: 2}),
       (c4:Commit {sha: '4d13800354bd0a903f41a4382e0a7c11f56f67a5', subject: 'Call the planner from main',
                   authored_at: datetime('2026-10-07T16:45:00Z'), files_changed: 2}),
       (c5:Commit {sha: '6e624258e2f4529895c014fe049ba7bf147d7530', subject: 'Fix README wording',
                   authored_at: datetime('2026-10-09T08:15:00Z'), files_changed: 1}),
       (readme:File {path: 'planner/README.md', name: 'README.md'}),
       (costs:File {path: 'planner/docs/costs.md', name: 'costs.md'}),
       (main:File {path: 'planner/src/main.rs', name: 'main.rs'}),
       (plan:File {path: 'planner/src/plan.rs', name: 'plan.rs'}),
       (ada)-[:AUTHORED]->(c1), (grace)-[:AUTHORED]->(c2), (ada)-[:AUTHORED]->(c3),
       (grace)-[:AUTHORED]->(c4), (noor)-[:AUTHORED]->(c5),
       (c1)-[:TOUCHED]->(readme), (c1)-[:TOUCHED]->(main),
       (c2)-[:TOUCHED]->(main), (c2)-[:TOUCHED]->(plan),
       (c3)-[:TOUCHED]->(costs), (c3)-[:TOUCHED]->(plan),
       (c4)-[:TOUCHED]->(main), (c4)-[:TOUCHED]->(plan),
       (c5)-[:TOUCHED]->(readme),
       (readme)-[:CO_CHANGED {count: 1}]->(main),
       (main)-[:CO_CHANGED {count: 2}]->(plan),
       (costs)-[:CO_CHANGED {count: 1}]->(plan)
Recorded result
(no rows) · nodes created: 12, relationships created: 17, properties set: 37, labels added: 12
Cypher
CREATE INDEX file_path IF NOT EXISTS FOR (f:File) ON (f.path);
CREATE INDEX commit_sha IF NOT EXISTS FOR (c:Commit) ON (c.sha);
CREATE INDEX person_email IF NOT EXISTS FOR (p:Person) ON (p.email);
CREATE FULLTEXT INDEX commit_subject IF NOT EXISTS FOR (c:Commit) ON EACH [c.subject]
Recorded result
(no rows) · indexes added: 4

Which files change together most often. CO_CHANGED points one way only, so this pattern returns each pair once:

Cypher
MATCH (a:File)-[r:CO_CHANGED]->(b:File)
RETURN a.path AS file, b.path AS co_changed, r.count AS times
ORDER BY times DESC, file
LIMIT 10
Recorded result
fileco_changedtimes
"planner/src/main.rs""planner/src/plan.rs"2
"planner/README.md""planner/src/main.rs"1
"planner/docs/costs.md""planner/src/plan.rs"1

3 rows

What changes alongside one file. The file is a parameter A named value a statement refers to as $name and the caller binds separately, such as with --param name=value on the CLI. Read more in the glossary, and the undirected pattern finds its partners on either side:

Cypher
MATCH (:File {path: $path})-[r:CO_CHANGED]-(other:File)
RETURN other.path AS coupled, r.count AS times
ORDER BY times DESC, coupled
Parameters
Recorded result
coupledtimes
"planner/src/main.rs"2
"planner/docs/costs.md"1

2 rows

Which files change most often:

Cypher
MATCH (c:Commit)-[:TOUCHED]->(f:File)
RETURN f.path AS file, count(c) AS commits
ORDER BY commits DESC, file
LIMIT 10
Recorded result
filecommits
"planner/src/main.rs"3
"planner/src/plan.rs"3
"planner/README.md"2
"planner/docs/costs.md"1

4 rows

Who has changed one file the most, the first person to ask about it:

Cypher
MATCH (p:Person)-[:AUTHORED]->(:Commit)-[:TOUCHED]->(:File {path: $path})
RETURN p.name AS person, count(*) AS commits
ORDER BY commits DESC, person
Parameters
Recorded result
personcommits
"Grace"2
"Ada"1

2 rows

Which files only one person has changed:

Cypher
MATCH (p:Person)-[:AUTHORED]->(:Commit)-[:TOUCHED]->(f:File)
WITH f, collect(DISTINCT p.name) AS authors
WHERE size(authors) = 1
RETURN f.path AS file, authors[0] AS author
ORDER BY file
Recorded result
fileauthor
"planner/docs/costs.md""Ada"

1 row

Which commits mention a word in their subject, with the full-text index:

Cypher
MATCH (c:Commit)
SEARCH c IN (FULLTEXT INDEX commit_subject FOR 'fix' LIMIT 10)
RETURN c.subject AS subject, c.authored_at AS authored_at
ORDER BY authored_at
Recorded result
subjectauthored_at
"Fix the cost estimate""2026-10-05T14:00:00Z"
"Fix README wording""2026-10-09T08:15:00Z"

2 rows

Join it with the files on disk

fs-to-graph writes the same :File keys for the same DIR, so importing both into one store adds folders and file text to the history:

relate run fs-to-graph planner -d ./code.rl
imported 3 folders, 4 files (6 CONTAINS edges) into the store — search it with:
  MATCH (f:File) SEARCH f IN (FULLTEXT INDEX file_content FOR 'term' LIMIT 10) RETURN f.path, f.name;

The store still holds 4 :File nodes, now with extension, size_bytes, and content as well. One query can then use both: which folders collect the most changes.

relate --database ./code.rl --read-only --mode table "MATCH (folder:Folder)-[:CONTAINS]->(f:File)<-[:TOUCHED]-(c:Commit) RETURN folder.path AS folder, count(DISTINCT f) AS files, count(c) AS changes ORDER BY changes DESC, folder"
+----------------+-------+---------+
| folder         | files | changes |
+----------------+-------+---------+
| "planner/src"  | 2     | 6       |
| "planner"      | 1     | 2       |
| "planner/docs" | 1     | 1       |
+----------------+-------+---------+

The codebase context graph recipe goes further on a real repository. It searches design records and their sections, then follows citations and crate dependencies hop by hop.

Verify

Right after the git-to-graph run, count the relationships by type, opening the store read-only:

relate --database ./code.rl --read-only --mode table "MATCH ()-[r]->() RETURN type(r) AS type, count(*) AS relationships ORDER BY type"
+--------------+---------------+
| type         | relationships |
+--------------+---------------+
| "AUTHORED"   | 5             |
| "CO_CHANGED" | 3             |
| "TOUCHED"    | 9             |
+--------------+---------------+

The counts match the run's summary: one AUTHORED per commit, 9 TOUCHED, and 3 CO_CHANGED.

If it goes wrong

Error: the extension child exited with 1: fatal: not a git repository (or any of the parent directories): .git (exit code 1). DIR is not inside a git checkout. The text after the colon is git's own message.

Error: the extension child exited with 1: invalid --max-changeset: '2000' is above the maximum 1000 (exit code 1). Pass a value from 1 to 1000.

git log output exceeds the 134217728-byte import limit (128 MiB); pass --max-commits or --since to read a smaller window (exit code 1, after Error: the extension child exited with 1:). The history is larger than one import reads. Import a window with --max-commits or --since, into a new store.

git was not found on PATH; install git and retry (exit code 1, after the same prefix). The extension could not start git.

Error: no installed action 'git-to-graph' — first-party extension available: relate extension install git-to-graph (exit code 2). Install the extension first; relate run fetches nothing.