Git layer
Repository checks, commit listing, diffs, blob reading, developer identity merging
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
| Command | Where | Purpose |
|---|---|---|
git rev-parse --is-inside-work-tree | gitService.ts | Is the folder a repository? |
git rev-parse HEAD | gitService.ts | Does it have at least one commit? |
git log --no-merges --format=%aN|%aE|%aI | gitService.ts | Every commit's author name, email, date → developer list |
git log --no-merges --format=%H␟%P␟%aN␟%aE␟%aI [--since=…] [-n N] | commitHistory.ts | The commits to replay |
git diff --no-color --unified=0 -w -M -C --diff-filter=ACDMRT <parent> <commit> | diffService.ts | Changed files, line ranges, blob ids — one call per commit |
git cat-file --batch | diffService.ts | Contents of every blob a commit needs — one call per commit |
git show <sha> | diffService.ts | Fallback 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()→falseon any error.hasCommits()→falseifHEADdoes 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 bymaxCommits.
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).0or unset with nosince→ 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[]:
| Field | Meaning |
|---|---|
status | added, modified, deleted, renamed, copied |
path / oldPath | Path after / before (renames and copies) |
blobBefore / blobAfter | Blob ids, null when the file does not exist on that side |
added | Line ranges (in the after file) that the commit added or changed |
removed | Line ranges (in the before file) that the commit removed or changed |
binary | Binary 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):
| Key | Built from | Example it catches |
|---|---|---|
exact: | Display name, trimmed, lowercased, spaces collapsed | Ayman Khalil on two different emails |
name: | Name with all non-alphanumerics removed and trailing digits stripped; only if ≥ 5 characters | MUSAALAHMED4 ↔ 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 generic | ayman@work.com ↔ ayman@gmail.com; 1234+ayman@users.noreply.github.com |
Protections against wrong merges:
- Collapsed names under 5 characters are ignored (
user1/user2,AnasvsAnas 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
displayNamefrom 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 →
developersin the config, or a.mailmap(11 — Customization guide). - Change the replay window →
analysis.maxCommits,analysis.since,--full-history. - Turn off automatic merging →
mergeSameNameAuthors: false.