04

Git layer

Repository checks, commit listing, diffs, blob reading, developer identity merging

5 min read7 sections

All Git access lives in src/git/. Every command runs through execFile("git", args, { cwd: root }). No shell is involved, so paths with spaces or special characters are safe. On Windows, execFile resolves git → git.exe.

Commands CleanLens runs

CommandWherePurpose
git rev-parse --is-inside-work-treegitService.tsIs the folder a repository?
git rev-parse HEADgitService.tsDoes it have at least one commit?
git log --no-merges --format=%aN|%aE|%aIgitService.tsEvery commit's author name, email, date → developer list
git log --no-merges --format=%H␟%P␟%aN␟%aE␟%aI [--since=…] [-n N]commitHistory.tsThe commits to replay
git diff --no-color --unified=0 -w -M -C --diff-filter=ACDMRT <parent> <commit>diffService.tsChanged files, line ranges, blob ids — one call per commit
git cat-file --batchdiffService.tsContents of every blob a commit needs — one call per commit
git show <sha>diffService.tsFallback for one blob if the batch read fails

%aN and %aE apply .mailmap, so a .mailmap file in the repo is the simplest way to fix identities at the Git level.

gitService.ts

GitService has three methods:

  • isRepository() → false on any error.
  • hasCommits() → false if HEAD does not resolve (an empty repo).
  • listCommits() → RawCommit[] (authorName, authorEmail, authorDate) for all non-merge commits. This is used only to build the developer list and activity stats. It is not limited by maxCommits.

commitHistory.ts — which commits are replayed

listCommitsDetailed(cwd, { maxCommits, since }):

  • Always --no-merges. Merge commits add no new code of their own; their content is covered by the commits being merged.
  • since → --since=<value> (any value Git accepts: 2026-01-01, "6 months ago", …).
  • maxCommits > 0 → -n <maxCommits> (the newest N). 0 or unset with no since → the whole history.
  • The result is reversed to oldest-first, so the engine can walk forward in time.
  • Each commit has its parents. A root commit has none and is diffed against Git's empty tree (4b825dc6…).

The default window is the newest 500 commits (DEFAULT_MAX_COMMITS in analyzeRepository.ts).

diffService.ts — what changed in a commit

commitChanges(parent, commit)

Runs one git diff and parses it into CommitFileChange[]:

FieldMeaning
statusadded, modified, deleted, renamed, copied
path / oldPathPath after / before (renames and copies)
blobBefore / blobAfterBlob ids, null when the file does not exist on that side
addedLine ranges (in the after file) that the commit added or changed
removedLine ranges (in the before file) that the commit removed or changed
binaryBinary file — skipped

The flags are chosen for fairness:

  • --unified=0 — no context lines, so hunk headers give exact changed ranges.
  • -w — ignore whitespace. A reformat (indentation, trailing spaces) does not count as changing a line, so it cannot transfer blame.
  • -M -C — detect renames and copies. Moving a file does not make the mover the author of its contents.
  • --diff-filter=ACDMRT — added, copied, deleted, modified, renamed, type-changed.

The parser reads diff --git, new file mode, deleted file mode, rename from/to, copy from/to, index <a>..<b>, ---/+++ and @@ hunk headers. It splits on \r?\n, so CRLF output from Windows Git parses correctly.

blobs(shas)

Reads many blobs with one git cat-file --batch process: it writes all ids to stdin and parses <sha> blob <size>\n<content>\n records from the raw bytes. If anything looks wrong (a missing record, a bad size), it discards the result and falls back to git show <sha> for each blob. That path is slower but always correct.

Helpers

  • rangeLineCount(ranges) — total lines in the ranges (used for analyzed lines).
  • lineInRanges(line, ranges), spanOverlapsRanges(start, end, ranges) — used to decide attribution confidence.

contributors.ts — who the developers are

aggregateContributors(commits, { identities, mergeSameName }) turns commits into developers.

Step 1 — group by email

Each commit is grouped by its lowercased email. If the email appears in the config's developers list, it is pinned to that entry's group instead, so all listed emails become one person.

For each group CleanLens tracks: the names seen, the emails, the active days (distinct UTC calendar days with a commit), the first and last commit dates, and the commit count. The primary name is the name on the group's earliest commit.

Step 2 — merge likely duplicates (default on)

When mergeSameNameAuthors is not false, groups that share any merge key are merged (union-find):

KeyBuilt fromExample it catches
exact:Display name, trimmed, lowercased, spaces collapsedAyman Khalil on two different emails
name:Name with all non-alphanumerics removed and trailing digits stripped; only if ≥ 5 charactersMUSAALAHMED4 ↔ MUSAALAHMED ↔ musa alahmed
mail:Email handle: text after the last + in the local part, or the whole local part; only if ≥ 3 characters and not genericayman@work.com ↔ ayman@gmail.com; 1234+ayman@users.noreply.github.com

Protections against wrong merges:

  • Collapsed names under 5 characters are ignored (user1 / user2, Anas vs Anas Daas).
  • Generic handles are ignored: noreply, no-reply, git, github, gitlab, admin, dev, me, hello, info, contact, mail, email, user, users, name, test.

Step 3 — label

  • A displayName from the config always wins.
  • A group with one name uses it.
  • A group that merged different names shows them all, earliest first: "Primary Name - Other Name".

Developers are sorted by commit count and get ids dev-1, dev-2, … in that order. The ids are only stable within one report.

blameService.ts (legacy)

BlameService and parsePorcelain() parse git blame --line-porcelain -w -M. They belong to the earlier "HEAD blame" engine and are no longer used by the pipeline. They remain because tests cover them and they are a useful helper for future features.

Customizing this layer

  • Merge identities → developers in the config, or a .mailmap (11 — Customization guide).
  • Change the replay window → analysis.maxCommits, analysis.since, --full-history.
  • Turn off automatic merging → mergeSameNameAuthors: false.