Solving Obsidian + Readwise Merge Conflicts with a Custom Git Driver
If you're using Obsidian with a Git-based backup system and the Readwise plugin, you've probably encountered this frustrating scenario: you sync your vault on two different machines, both have new
highlights from Readwise, and suddenly you're staring at merge conflicts in a JSON file you never intended to edit manually.
I got tired of resolving these conflicts by hand. So I asked Claude Code to help me build a solution.
The Problem
My Obsidian vault is synced across multiple devices using Git. The Readwise plugin stores its state in .obsidian/plugins/readwise-official/data.json, which contains:
- A
lastSavedStatusIDfield tracking the sync progress - A
booksIDsMapobject mapping file paths to Readwise book IDs
When I read articles on my phone and sync Readwise on my laptop, then do the same on my desktop, Git sees two divergent versions of this file. The result? Merge conflicts with those dreaded <<<<<<<
markers in a JSON file.
<<<<<<< HEAD
"lastSavedStatusID": 23934436,
=======
"lastSavedStatusID": 23924421,
>>>>>>> other-branchThe fix is obvious to a human: keep the highest ID (it represents the most recent sync) and merge the book mappings. But doing this manually every time is just plain annoying.
The Solution: Custom Git Merge Drivers
Git has a lesser-known feature called custom merge drivers. Instead of showing conflict markers, Git can call a custom script to resolve conflicts automatically.
I described my problem to Claude Code: "I want a merge rule for readwise-official/data.json. In case of conflict, always keep the highest ID."
Claude Code immediately understood the requirement and created a solution using jq for proper JSON manipulation. When I later realized the booksIDsMap also needed merging, Claude updated the script
accordingly.
Testing First with Bats
I didn't want to blindly trust a script that modifies my data during merges. I asked Claude Code to write tests before we finalized the implementation.
Claude chose Bats (Bash Automated Testing System), a testing framework for Bash scripts.
This was a good use case to finally get started with bats.
Here's what the test structure looks like:
setup() {
TEST_DIR="$(mktemp -d)"
SCRIPT_DIR="$BATS_TEST_DIRNAME/.."
BASE_JSON='{
"token": "test-token",
"lastSavedStatusID": %s,
"booksIDsMap": {%s}
}'
}
teardown() {
rm -rf "$TEST_DIR"
}
create_json() {
local status_id="$1"
local books_map="$2"
printf "$BASE_JSON" "$status_id" "$books_map"
}I was surprised, in a good way, to see that Claude thought about using fixture builder like the create_json function.
It helped creating some nice tests. Here are a few examples:
@test "lastSavedStatusID: takes other when other > current" {
create_json "100" "" > "$TEST_DIR/ancestor.json"
create_json "150" "" > "$TEST_DIR/current.json"
create_json "200" "" > "$TEST_DIR/other.json"
run "$SCRIPT_DIR/readwise-merge.sh" \
"$TEST_DIR/ancestor.json" \
"$TEST_DIR/current.json" \
"$TEST_DIR/other.json"
[ "$status" -eq 0 ]
result=$(grep -o '"lastSavedStatusID": [0-9]*' "$TEST_DIR/current.json" | grep -o '[0-9]*')
[ "$result" -eq 200 ]
}
@test "booksIDsMap: no data loss - all unique entries from both sides are preserved" {
create_json "100" '' > "$TEST_DIR/ancestor.json"
create_json "100" '"current1.md": "1", "current2.md": "2", "shared.md": "100"' > "$TEST_DIR/current.json"
create_json "100" '"other1.md": "3", "other2.md": "4", "shared.md": "999"' > "$TEST_DIR/other.json"
run "$SCRIPT_DIR/readwise-merge.sh" \
"$TEST_DIR/ancestor.json" \
"$TEST_DIR/current.json" \
"$TEST_DIR/other.json"
[ "$status" -eq 0 ]
# Should have exactly 5 unique entries
count=$(jq '.booksIDsMap | length' "$TEST_DIR/current.json")
[ "$count" -eq 5 ]
# Shared key should have current's value, not other's
shared_value=$(jq -r '.booksIDsMap["shared.md"]' "$TEST_DIR/current.json")
[ "$shared_value" = "100" ]
}Running bats tests/readwise-merge.bats gives us confidence that the driver behaves correctly:
ok 1 lastSavedStatusID: keeps current when current > other
ok 2 lastSavedStatusID: takes other when other > current
ok 3 lastSavedStatusID: keeps current when equal
ok 4 booksIDsMap: keeps current entries when other has none
ok 5 booksIDsMap: adds entries from other that current doesn't have
ok 6 booksIDsMap: merges entries from both current and other
ok 7 booksIDsMap: no duplicates when same entry in both
ok 8 booksIDsMap: current wins when same key has different value
ok 9 booksIDsMap: no data loss - all unique entries from both sides are preserved
ok 10 combined: handles both lastSavedStatusID and booksIDsMap
ok 11 output is valid JSONThe Merge Driver
Here's the final script:
#!/bin/bash
# Custom merge driver for readwise-official/data.json
# - Keeps the highest lastSavedStatusID
# - Merges booksIDsMap (current wins on conflicts)
# Arguments: %O %A %B (ancestor, current, other)
ANCESTOR="$1"
CURRENT="$2"
OTHER="$3"
# Extract values using jq
CURRENT_ID=$(jq -r '.lastSavedStatusID // 0' "$CURRENT")
OTHER_ID=$(jq -r '.lastSavedStatusID // 0' "$OTHER")
# Determine highest ID
if [ "$OTHER_ID" -gt "$CURRENT_ID" ]; then
HIGHEST_ID="$OTHER_ID"
else
HIGHEST_ID="$CURRENT_ID"
fi
# Merge: start with other's booksIDsMap, overlay current's (current wins on conflicts)
# Then update lastSavedStatusID with highest value
jq -s '
.[0] as $current |
.[1] as $other |
$current |
.lastSavedStatusID = '"$HIGHEST_ID"' |
.booksIDsMap = ($other.booksIDsMap // {}) + ($current.booksIDsMap // {})
' "$CURRENT" "$OTHER" > "$CURRENT.tmp" && mv "$CURRENT.tmp" "$CURRENT"
exit 0Installation
1. Create the driver script
mkdir -p .git-merge-drivers
# Save the script above as .git-merge-drivers/readwise-merge.sh
chmod +x .git-merge-drivers/readwise-merge.sh2. Create .gitattributes
.obsidian/plugins/readwise-official/data.json merge=readwise-highest-id3. Configure Git
git config merge.readwise-highest-id.name "Readwise merge - keep highest ID and merge books"
git config merge.readwise-highest-id.driver "$(pwd)/.git-merge-drivers/readwise-merge.sh %O %A %B"4. If you already have a conflict
git checkout -m .obsidian/plugins/readwise-official/data.json
git add .obsidian/plugins/readwise-official/data.json
git rebase --continue # or git merge --continueConclusion: Beyond Readwise
This experience got me thinking about other merge pain points.
Lock files. Anyone who has resolved conflicts in package-lock.json, yarn.lock, or composer.lock knows the frustration. These files are generated, not authored. The correct resolution is almost
always "regenerate the file"—run npm install, yarn install, or composer install after merging package.json. A custom merge driver could automate this.
LLM-backed merging. For simple, rule-based conflicts, we could describe the merge logic in a prompt and let an LLM resolve them. "Always keep the higher version number." "Merge arrays by taking the
union." "For this config file, prefer values from the current branch." This could work for cases where the rules are clear but the file format is complex.
Deterministic scripts over manual resolution. The real win here is moving from ad-hoc manual fixes to deterministic solutions. Given the same inputs, the merge driver always produces the same
output. No human judgment required, no mistakes from rushing through conflicts at 6 PM on a Friday.
The next time you find yourself resolving the same type of conflict repeatedly, consider: can you describe the resolution rules? If yes, you can probably automate it.
- Improve your automated testing : You will learn how to fix your tests and make them pass from things that slow you down to things that save you time. This is a self-paced video course in French.
- Helping your teams: I help software teams deliver better software sooner. We'll work on technical issues with code, test or architecture, or the process and organization depending on your needs. Book a free call where we'll discuss how things are going on your side and how I can help you.
- Deliver a talk in your organization: I have a few talks that I enjoy presenting, and I can share with your organization(meetup, conference, company, BBL). If you feel that we could work on a new topic together, let's discuss that.
