- Book: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- Also by me: Pocket Guides for Developers — the 8-book series
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
Three coding agents, three branches, one Go service. Each agent fixed its bug and then did the polite thing: it added a bullet under ## [Unreleased] in CHANGELOG.md. The first branch merges clean. The second stops on conflict markers in a file where both sides are right. The third one bumped a dependency, so its pull request opens with a screen of go.sum checksums before you reach the code you need to read.
Git merges and diffs every file the same way unless you tell it otherwise. The place you tell it is .gitattributes, and eight lines cover the files parallel agents tend to collide on: the changelog, lockfiles, generated code and snapshot fixtures.
The whole file, then line by line
Here it is. Commit it on main before you start the agents, so every branch cut from main carries the same rules.
# 1. LF in the repo and in every worktree
* text=auto eol=lf
# 2-3. Function names in hunk headers
*.go diff=golang
*.php diff=php
# 4. Keep both sides' changelog bullets
CHANGELOG.md merge=union
# 5-6. Lockfiles: no local diff hunks
go.sum -diff linguist-generated
package-lock.json -diff
# 7. Codegen GitHub can't spot on its own
api/gen/** linguist-generated
# 8. Snapshot images: never convert, never merge
testdata/**/*.png binary
Two rules from the gitattributes docs explain the layout. When several patterns match a path, "a later line overrides an earlier line", and that happens per attribute. So line 8 can switch off the text that line 1 switched on, for PNGs only. And a pattern that names a directory does not match the files inside it: write api/gen/**, not api/gen/.
Line 1: * text=auto eol=lf. Git decides per file whether it is text, and for text files it stores LF. With eol=lf, the working tree gets LF on checkout too, on every OS. That matters when one agent runs on Windows or one tool saves with CRLF: without the rule, a file whose endings flip shows every line as changed.
Files already committed with CRLF are not converted on their own; the docs give the fix, run once from a clean tree: git add --renormalize ., then commit. To check, git ls-files --eol prints each file's endings in the index (i/), in the working tree (w/) and the attribute in force (attr/). If a Windows script needs CRLF, add *.bat text eol=crlf below line 1.
Lines 2 and 3: diff=golang and diff=php. Every hunk starts with @@ -a,b +c,d @@ and then a line of context git picked as the "function name". By default that is the nearest line that "begins with an alphabet, an underscore or a dollar sign", the same rule GNU diff -p uses. Go puts func at column zero, so the default often finds it anyway. diff=golang makes the choice deliberate with a Go pattern. The docs also say each built-in language ships a word regex, so git diff --word-diff splits Go tokens properly.
PHP is the case that needs it: methods sit indented inside a class, the default rule skips indented lines, and the hunk header usually names the class instead of the method. Both golang and php are in git's built-in list, so there is nothing to configure beyond the attribute.
Line 4: merge=union for the changelog
This is the line that ends the fake conflict in the opening. The docs describe the built-in union driver like this: "Run 3-way file level merge for text files, but take lines from both versions, instead of leaving conflict markers."

Three branches, one changelog: union keeps every new line instead of stopping on markers.
Two agents each add a bullet under ### Added. A normal merge sees both sides inserting at the same spot and stops. Union keeps both bullets. That is what an append-only file wants.
Read the warning that comes with it, though, because it is in the same paragraph of the docs: union "tends to leave the added lines in the resulting file in random order and the user should verify the result. Do not use this if you do not understand the implications." In practice that means two things for a changelog:
- Union is right for added lines and wrong for edited ones. If two branches both change the same existing line, say a version number in a heading, you get both versions and no warning.
- It never applies to code. Keep it on files where every line stands alone.
You can try it before you commit the rule. git merge-file takes three files in the order current, base, other, and --union resolves conflicts by keeping both sides:
git merge-file -p --union \
changelog-ours.md changelog-base.md changelog-theirs.md
-p prints the result instead of overwriting the first file, so it is safe to run on copies.
One more thing to check: whether your host's merge button honours the attribute. A GitLab issue notes that GitLab reads built-in drivers like union from .gitattributes, while custom drivers defined in git config don't run in merge requests. If branches land through a web button on your host, test it on a throwaway pull request first.
Lines 5 to 7: keep lockfiles and codegen out of the way
Lockfiles and generated code change for real, and you do want them committed. You don't want to read them line by line in a review, and you don't want an agent spending context on them when it runs git diff to check its own work. Two different attributes cover the two places you read diffs.
-diff is for your terminal. The docs: a path with diff unset will "generate Binary files differ". So git diff and git log -p print a one-line notice for go.sum instead of hunks of hashes. The file still merges as text, because -diff says nothing about merging. When you want the content, git diff --text go.sum shows it anyway (--text means "treat all files as text").
go.sum is a good target. The Go modules reference describes it as "cryptographic hashes of the module's direct and indirect dependencies". The change a human reviews is in go.mod, which stays diffable. package-lock.json gets the same treatment if the repo has a web front end.
linguist-generated is for GitHub. GitHub's docs say files marked with it are hidden by default in diffs and left out of the repository's language statistics. GitHub already spots some files without help. Linguist's generated.rb catches package-lock.json and composer.lock by name, and Go files with a // Code generated line in their first 40 lines. That is the Go convention (^// Code generated .* DO NOT EDIT\.$), so most .pb.go and other codegen output is covered already.
go.sum is not on that list, which is why line 5 sets both attributes. Line 7 is for generated output that carries no such header, like a bundled OpenAPI spec or a generated TypeScript client under api/gen/.
Don't reach for binary on lockfiles. It is a macro for -diff -merge -text, and with merge unset git takes the current branch's version and declares a conflict every time both branches change the file. For a lockfile, a text merge followed by go mod tidy or npm install is the better path.
Line 8: binary for snapshot fixtures
Image snapshots are the opposite case. A line merge of two PNGs is never useful, and a converted byte breaks the test. Under line 1 alone, text=auto leaves git to decide by itself whether each PNG is text; line 8 takes that decision out of git's hands. The docs define the macro as [attr]binary -diff -merge -text: no text diff, no line merge, no end-of-line conversion. Because it sits after line 1, it wins for these paths.
testdata/ is the right home for them in Go. The go command docs say the tool "will ignore a directory named 'testdata', making it available to hold ancillary data needed by the tests."
When two agents both re-record a snapshot, the merge now stops on that file, keeping your branch's copy, and you regenerate it from the merged code. For text golden files that get regenerated whole, use -merge on its own instead. You keep a readable diff in review and still get the stop-and-regenerate behaviour on merge.
Prove each line applies with git check-attr
A typo in a pattern fails silently. git check-attr tells you what git will actually use for a path. With -a it lists every attribute that is set, unset or given a value for each path you name:
git check-attr -a -- CHANGELOG.md go.sum \
api/gen/client.ts testdata/golden/home.png
The output format is <path>: <attribute>: <info>, one line per attribute. You are looking for merge: union on the changelog, diff: unset and linguist-generated: set on go.sum, and diff, merge and text all unset on the PNG. Line 1 matches every path, so each one prints at least eol: lf and a text line. If that is all a path shows, none of the specific lines matched it, so check that pattern for a typo. Add --cached to read .gitattributes from the index only, which tells you what is committed rather than what sits in your working tree. Run it once before you cut the agents' branches.
Reviewing several agents' branches by file
I build Hermes IDE, so weigh this section with that in mind. In Hermes, ⌘N (Ctrl+Shift+N on Windows and Linux) starts each task on its own hermes/<name> branch in its own worktree, with the task as the agent's first prompt. Those branches and worktrees are plain git, so a .gitattributes committed on the branch you start from is in every worktree.

Two agent sessions side by side: Codex on orders-types and Claude Code on orders-api, each in its own hermes/ worktree, with the sessions sidebar (status, context use, cost, branch) on the left.
The Review Desk (⌘G, or Ctrl+Shift+G) shows a task's changes by turn or by file, with risk flags, and a line comment goes back to the agent that wrote that line. In either view, line 1 keeps a commit from showing every line changed over line endings, and the changed-files list lets you pass over go.sum and the changelog to spend your attention on the code.

The Review Desk (⌘G) in By turn view: two turns, turn 1's diff of src/db.ts, the changed-files list, and a commit message drafted from the turns.
When a task is ready, Land commits, opens a pull request or merges locally into the branch the task started from, and it can be undone. If the remote is on GitHub, a pull request opened that way gets GitHub's collapsed view for go.sum and api/gen/. Hermes is free to download, source-available under BSL 1.1, and runs on macOS, Windows and Linux. It is not affiliated with Nous Research, Anthropic, OpenAI or any agent vendor. Download it here, and the source is on GitHub.
If this was useful
A .gitattributes file only calms the merges. Whether three agents can work on one Go service without stepping on each other comes down to how the code is split: clear packages, clear boundaries, small files that change for one reason. Thinking in Go is my two-book series on that, first the language itself, then hexagonal architecture in Go, where the domain sits behind ports and the adapters change on their own.

Top comments (0)