Notes /
Untangling 1,500 lines of merge conflicts
My diploma project had conflict markers committed into 59 files. Here's how they piled up, why “keep the newer side” wasn't enough, and how the compiler got me out.
01
How it happened
I worked on Rental Portal from more than one machine. The routine was: stash local work, pull, pop the stash back.
git stash
git pull
git stash pop
When the stash and the pulled code touched the same lines, git stash pop wrote conflict markers into the file, labelled Updated upstream and Stashed changes. That part is normal. The mistake came next: running git add . and committing anyway.
git add marks a conflicted file as resolved, whether or not it is. The markers became ordinary text in the repository. The next stash pop then put new markers around text that already contained old ones. A few weeks of that, and some files had four layers.
02
What it looked like
A simplified example from one of the Razor views:
<<<<<<< Updated upstream
<<<<<<< Updated upstream
string lang = Cookies["lang"] ?? "en";
=======
string lang = Cookies["lang"] ?? "bg";
>>>>>>> Stashed changes
var labels = LoadLabels(lang);
…the rest of the page…
<<<<<<< Updated upstream
</html>
>>>>>>> Stashed changes
=======
</html>
>>>>>>> Stashed changes
In total there were about 1,500 marker lines across controllers, views, stylesheets, scripts and translation files. The project hadn't compiled in a long time.
03
Why the obvious fix fails
The obvious fix is to treat the markers as nested blocks and keep the Stashed changes side of each one, since that's the newer work. It doesn't work, because the markers aren't really nested. The old markers are just text inside the new layer, so the opening and closing lines don't pair up the way a parser expects.
Take the outermost layer of the example literally, and its “newer side” is a single </html>. The whole page in between counts as the older side and gets thrown away.
04
A rule that matches how it happened
Each pop put a newer version of a region right after an older one, with a ======= line between them. Text that both versions shared sat outside the separators. So the rule became: a separator replaces the version just before it. A closing marker never throws anything away.
def resolve(lines):
out, mark = [], None
for line in lines:
if line.startswith("<<<<<<< "):
mark = len(out)
elif line.rstrip() == "=======":
if mark is not None:
del out[mark:] # the older version goes
mark = len(out)
elif line.startswith(">>>>>>> "):
pass # shared text stays
else:
out.append(line)
return out
On the example above, this keeps the "bg" line, the whole page, and exactly one </html>. That's the version I had actually been working on.
05
Checks, not trust
No single rule fit every file, so each file type got a check instead:
- C# and Razor:
dotnet build. Razor views compile too, so an unclosed<div>or a variable declared twice shows up as an error with a line number. - JavaScript:
node --checkon every file. - JSON translation files: parse every one.
- CSS: count the braces. One missing
}quietly swallows every rule after it.
The rule resolved most files. Where it didn't, the errors pointed straight at the problem, and about a dozen files needed picking apart by hand. The build went from dozens of errors to zero.
A build that passes only proves the code compiles. Before I'd call it finished, I'd still click through every page.
06
What I do now
Git can already refuse to commit leftover markers. git diff --check reports them, so a three-line pre-commit hook stops the whole mess at the first commit:
#!/bin/sh
# .git/hooks/pre-commit
git diff --cached --check || exit 1
And for moving work between machines, a pushed branch beats a stash. It's visible, it's backed up, and it can't be popped twice.