Gitmerge conflictsbranchingMindThreadengineering workflow

Merging 106 commits into a branch that had been open for two and a half weeks: how we sorted and resolved 44 conflicts

· · 13 min read
Table of Contents
  1. How did it pile up to 44 files?
  2. Four groups, one rule each
  3. Shared code is the hardest: you have to read the call sites
  4. Merge config files by key, not by line
  5. Two test stages, plus a test that checks the tests are wired in
  6. Main bundle over budget, and no raising the budget
  7. Walk the preview site
  8. A merge is not a feature port
  9. Conflict sorting script
  10. By-key three-way merge for JSON
  11. What will we do next time?

When a long-lived branch merges mainline, what really makes 44 conflicted files hard is judging each one on the spot, with the criteria drifting from file to file. This time we sorted before solving: four groups of conflicted files, one default rule per group, and file-by-file thinking only for the exceptions. After that we ran two test stages, held the main bundle under its budget, and walked the preview site with a test account. All 44 files resolved, all tests passed, the main bundle ended up smaller than before the merge, and the walkthrough caught a missing redirect that neither git nor the tests noticed.

How did it pile up to 44 files?

MindThread 2.0 is a branch that rebuilds the interface. It was cut from mainline on October 17, and for the first ten days we merged mainline into it almost every day. After October 27 the 2.0 side was busy filling feature gaps and went a week without syncing. In that week mainline grew by 106 commits touching 350 files compared with the common ancestor; the 2.0 side added 22 commits of its own since the last sync.

A trial merge during an audit earlier on November 3 showed 92 commits and 38 conflicted files. When we actually merged that evening, it was 106 and 44. A few hours added 6 files, which is lesson one: a long-lived branch cannot stop syncing.

The merge itself was handed to a Claude Code worker. Its work order spelled out the grouping and the rule for each group, and the report it returned lists every file, how it was resolved, and why.

Four groups, one rule each

List the conflicted files with git diff --name-only --diff-filter=U and sort them by path:

  1. Shared code, 25 files (backend API, shared lib, hooks, components): start from mainline and re-apply the 2.0 changes hunk by hunk. That week mainline fixed problems real members ran into in production, which cannot be dropped. The 2.0 changes are new features, such as the in-app assistant Xiaomai, the no-signup trial and the three-block overview, which cannot be dropped either.
  2. Old-version pages, 5 files: take mainline. The old version is still live, and 2.0 barely touched these pages.
  3. New-version pages, 3 files: take 2.0 and pick only what is needed from mainline. The settings page only took mainline's new "delete account" card, placed under the General tab; the team section mainline changed is not used in 2.0, so it stayed out.
  4. Config and data files, 7 files (package.json, the lock file, vercel.json, two locale files, .gitignore, a maintenance doc): keep both sides.

The 4 test files are not a group of their own. They follow the side they test: tests of mainline backend behavior take mainline, tests of 2.0 defaults take 2.0, and when both sides added cases, they are combined.

Sorting set a default direction for every file, so the thinking left was only for the exceptions.

Shared code is the hardest: you have to read the call sites

The Threads image-posting function is a good example. 2.0 added a test-only wait setting as the 6th parameter; the same week, mainline added replyToId in the 6th position too, used to split a video caption into a thread. Looking at the function alone, either order makes sense. But the production call site was written on mainline, and its 6th argument is replyToId. Keep the 2.0 order and that argument lands in the wait setting's slot. So the signature became …, topicTag?, replyToId?, wait, with the wait setting moved to the end (only tests pass it), and the matching test was updated to the new order.

A few other typical shapes:

  • Each side added a piece of state: keep both. In the hook that reads the subscription plan, 2.0 switched "loading" to check which user the last snapshot belongs to, and mainline added a "read failed" flag so a member is not treated as free tier and blocked when the plan cannot be read.
  • Combining flows means ordering them. For removing an account, 2.0 checks ownership first and cancels scheduled IG and FB posts; mainline moved the cleanup to the server and also moves orphaned drafts. The combined order: check ownership, cancel scheduled posts, remove on the server (if the posts were cancelled but the server call fails, throw a dedicated error code), then clear the cache.
  • One side deleted, the other modified. 2.0 had already deleted the old reauthorization banner in favor of a single alert bar; mainline was still editing it. Nothing imported it, so it was deleted, following 2.0.
  • Both halves are right: take half of each. In the account cap notification email, the link stayed as 2.0's /settings/plan?addon=1, because 2.0 has no /pricing page; the recipient now comes from mainline's new function, so suspended accounts no longer get the email.

Merge config files by key, not by line

package.json and the locale files are JSON, and merging them by line easily produces duplicate keys or a missing comma. We did a three-way merge by key:

  • package.json: mainline's new scripts, overrides and package versions all came in; 2.0's two prebuild stages stayed, with mainline's tests appended.
  • Lock file: never hand-edited. Take mainline's as the base, then run npm install once to add what 2.0 needs.
  • vercel.json: the two SPA rewrite path lists became their union; the crons came to 40 in total, and the one with different schedules on each side (auto-reply) took mainline's every 5 minutes.
  • Locale files: mainline added 352 leaf keys to each locale file that week, and not a single key had been changed to different values on both sides. After a by-key merge, nothing needed a human pick.

Two test stages, plus a test that checks the tests are wired in

The 2.0 pre-build check has two stages: the first runs 2.0's own tests, and onto the end of the second we appended mainline's 8 tests and its run-tests.mjs @prebuild list. The merge brought mainline's test files in, but tests that are not wired into the build never run. So a separate check confirms that every test on the list is actually wired into the pre-build step, and it was updated this time to recognize mainline's list.

A few test assertions had to follow the 2.0 structure: a route check now looks at the old version's route tree and the 2.0 redirect table, one check accepts both state-handling styles, and one fixture gained an account ID. The rule: change where a test looks, not what it requires.

Main bundle over budget, and no raising the budget

After the merge the main bundle was 148.8 KB gzipped against a 143.3 budget. Most of the growth came from mainline's locale strings (about 7.7 KB) and the update, offline and install prompt modules.

The 2.0 bundle test explicitly forbids raising the budget by rewriting the baseline, and it was written for moments like this. We lazy-loaded the bell and the feedback button in the top bar instead, with same-size placeholders until they load so the top bar does not shift. The main bundle dropped to 135.2 KB, smaller than 2.0's 139.4 before the merge.

What we did not solve is written down too: total first-screen weight (gzipped) went from a 336 KB baseline to 401.8 KB. That number has no budget. Most of it is the preloaded vendor and firebase chunks growing after mainline's dependency upgrades, and we have not yet traced which ones.

Walk the preview site

Passing tests and a passing build do not mean members will be fine. On the 2.0 preview site, with a test account in a test database, we walked 7 points: sign in, the three-block overview, the five publishing steps, one question to Xiaomai, one no-signup trial post, the engagement page and the settings page. Publishing went only as far as the confirmation dialog; we never pressed send. Zero console errors, no horizontal scrolling on mobile.

The walkthrough caught one gap: the old /distribute URL did not redirect to the 2.0 publishing page. The redirect table had it, but the router shell's list of legacy paths was missing it, and no test caught it. The fix went into a second commit.

A merge is not a feature port

Before the merge we had counted 7 member-visible features that 2.0 still lacked compared with the old version. After it: 1 came along naturally (three new cloud patrol settings, because the 2.0 engagement page uses the same component), 1 came halfway (a failed plan read no longer blocks anyone and the site-wide offline banner arrived, but the publish button is not yet wired to offline detection), and the other 5 did not, such as manual threads on the publish page and deleting a published post from Threads.

The reason is simple: a merge brings in code. If a component is only mounted on old-version pages and 2.0 does not mount it, members do not see it.

Conflict sorting script

When a merge stops on conflicts, run this from the repo root to see the whole picture before touching anything. Swap the path rules for your own repo's:

// classify-conflicts.cjs: when a merge stops on conflicts, sort the files first, one resolution rule per group
// usage: run node classify-conflicts.cjs from the root of the conflicted repo
const { execSync } = require('child_process')
const fs = require('fs')

// matched top to bottom, first hit wins; replace the path rules with your own repo's
const GROUPS = [
  { key: 'config', rule: 'keep both: merge by key, regenerate the lock file', test: /(^|\/)(package(-lock)?\.json|vercel\.json|\.gitignore)$|\/i18n\/[^/]+\.json$|^docs\// },
  { key: 'tests', rule: 'follow the side it tests; combine when both sides add cases', test: /(^|\/)(scripts\/test-[^/]+|[^/]+\.test\.[jt]sx?)$/ },
  { key: 'old-only', rule: 'old-version only: take mainline', test: /^src\/pages\/v1\// },
  { key: 'new-only', rule: 'new-version only: take the new branch', test: /^src\/pages\/v2\// },
  { key: 'shared', rule: 'shared: start from mainline, re-apply the new branch hunk by hunk', test: /./ },
]

const files = execSync('git diff --name-only --diff-filter=U', { encoding: 'utf8' }).split('\n').filter(Boolean)
const out = Object.fromEntries(GROUPS.map((g) => [g.key, []]))
for (const f of files) {
  const g = GROUPS.find((x) => x.test.test(f))
  const text = fs.existsSync(f) ? fs.readFileSync(f, 'utf8') : ''
  const hunks = (text.match(/^<{7} /gm) || []).length   // conflict hunks in this file
  out[g.key].push(`${f}${hunks ? ` (${hunks} hunks)` : ' (modify/delete)'}`)
}
console.log(`${files.length} conflicted files`)
for (const g of GROUPS) {
  if (!out[g.key].length) continue
  console.log(`\n## ${g.key}: ${out[g.key].length} files | ${g.rule}`)
  for (const line of out[g.key]) console.log('  ' + line)
}

By-key three-way merge for JSON

For locale files, config and other JSON, use the three versions in the git index (:1 common ancestor, :2 ours, :3 theirs) and merge by key. Keys changed on both sides keep ours and get listed, and the script exits 2 so you review each one:

// json-merge3.cjs: three-way merge of a conflicted JSON file (locale files, config) by key
// usage: node json-merge3.cjs src/i18n/en.json   (reads git index stages :1 base, :2 ours, :3 theirs)
const fs = require('fs')
const { execSync } = require('child_process')
const file = process.argv[2]
const show = (stage) => execSync(`git show :${stage}:${file}`, { encoding: 'utf8', maxBuffer: 64 << 20 })
const oursRaw = show(2)
const base = JSON.parse(show(1)), ours = JSON.parse(oursRaw), theirs = JSON.parse(show(3))

const isObj = (x) => x && typeof x === 'object' && !Array.isArray(x)
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b)
const log = { added: [], tookTheirs: [], deleted: [], bothChanged: [] }

function merge(b, o, t, path) {
  if (!isObj(o) || !isObj(t)) {
    if (same(o, t)) return o
    if (same(b, o)) { log.tookTheirs.push(path); return t }    // only theirs changed
    if (same(b, t)) return o                                   // only ours changed
    log.bothChanged.push(path); return o                       // both changed: keep ours, list it for a human
  }
  const bb = isObj(b) ? b : {}
  const out = {}
  for (const k of Object.keys(o)) {
    if (k in t) out[k] = merge(bb[k], o[k], t[k], `${path}.${k}`)
    else if (k in bb && same(bb[k], o[k])) log.deleted.push(`${path}.${k}`)   // theirs deleted, ours untouched: delete
    else out[k] = o[k]
  }
  const keys = Object.keys(out)
  const tKeys = Object.keys(t)
  tKeys.forEach((k, i) => {                                    // keys added by theirs: insert after their previous sibling
    if (k in o || k in bb) return
    log.added.push(`${path}.${k}`)
    let at = i === 0 ? 0 : keys.length
    for (let p = i - 1; p >= 0; p--) { const pos = keys.indexOf(tKeys[p]); if (pos >= 0) { at = pos + 1; break } }
    keys.splice(at, 0, k); out[k] = t[k]
  })
  return Object.fromEntries(keys.map((k) => [k, out[k]]))
}

const merged = merge(base, ours, theirs, '')
const indent = (oursRaw.match(/^\{\r?\n( +)"/) || [, '  '])[1].length
const nl = oursRaw.includes('\r\n') ? '\r\n' : '\n'
fs.writeFileSync(file, JSON.stringify(merged, null, indent).replace(/\n/g, nl) + (/\r?\n$/.test(oursRaw) ? nl : ''))
console.log(`${file}: added by theirs ${log.added.length} | took theirs ${log.tookTheirs.length} | deleted ${log.deleted.length} | both changed ${log.bothChanged.length}`)
if (log.bothChanged.length) { console.log('both changed (kept ours, review each):\n  ' + log.bothChanged.join('\n  ')); process.exitCode = 2 }

While writing this post we ran both in a scratch repo with deliberately created conflicts: 6 conflicted files sorted into 5 groups, and the JSON script handled added by theirs, changed only by theirs, deleted by theirs, and changed on both sides as specified, listing the key changed on both sides. After merging, remember git add, then run the full test suite.

What will we do next time?

  • Sync daily. Even on a day with no time to resolve conflicts, run git merge --no-commit --no-ff to see the numbers, then git merge --abort.
  • Put the grouping rules in the work order. Whether a person or an agent does the merge, agree on the criteria first, so the report can be checked file by file.

For large batches of changes our habit is always the same: inventory and sort first, then change, then re-check afterwards. Another example is triaging Dependabot alerts across 49 repos in one night.

FAQ

How often should a long-lived branch sync with mainline?

As often as you can. For its first ten days our 2.0 branch merged mainline almost daily. After October 27 it went a week without syncing, mainline grew by 106 commits, and the merge hit 44 conflicted files. A trial merge earlier the same day had shown 92 commits and 38 files, so a few hours added 6 files. We plan to sync daily from now on, and on days with no time to resolve, still do a trial merge to see the count.

How do I resolve a package-lock.json conflict?

Do not edit the lock file hunk by hunk. Merge package.json by key first, take one side's lock file as the base (we took mainline's, since it had more dependency changes), and run npm install once so it adds whatever the other side needs. Then run the full test suite and the build to confirm.

The bundle is over budget after a merge. Can I just raise the budget?

Our tests explicitly forbid raising the budget by rewriting the baseline, and this rule exists for exactly this moment. Our main bundle reached 148.8 KB gzipped after the merge, over the 143.3 budget. We lazy-loaded the notification bell and feedback button in the top bar, with same-size placeholders until they load, and the main bundle dropped to 135.2 KB, smaller than before the merge.

Weekly AI Automation Playbook

No fluff — just templates, SOPs, and technical breakdowns you can use right away.

Join the Solo Lab Community

Free resource packs, daily build logs, and AI agents you can talk to. A community for solo devs who build with AI.

Want to try it yourself?

UltraProbe is free and needs no sign-up. One scan tells you whether Google and AI engines can find your site.