Give the laptop client the base tracking the phone already has
Without a record of the last agreement, the client cannot tell "I have unsaved edits" from "I am simply behind", and reported the second as the first every time the phone pushed, leaving a .remote- file in the notes directory each time. It now keeps the pristine text and its ETag under XDG_STATE_HOME and merges three ways, matching the phone. Two bugs found while testing that, both of which manufacture conflicts rather than lose data, and both of which were invisible until a real edit happened on each device at once. diff3 was the wrong merge tool. It reports a conflict even when both sides make the identical change, and ticking the same box on both devices is an ordinary thing to do. git merge-file handles that cleanly and agrees with the Android merge about what counts as a genuine conflict. The merge result was not being recorded as the new base, so one sync later the base was a version behind and an already merged edit looked like a fresh change from both sides. Every point where the two are known to agree now records that agreement. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PU5ZFfQFtDTFqqhvMTWdGH
This commit is contained in:
co-authored by
Claude Opus 5
parent
dd23ca67cd
commit
2bf910757a
+43
@@ -309,6 +309,49 @@ tap in that sample happened to be in the unscrolled top of the document, where
|
||||
the two coordinate systems coincide. The evidence was consistent with both
|
||||
explanations, and only looked decisive.
|
||||
|
||||
## diff3 calls an identical change on both sides a conflict
|
||||
|
||||
The laptop client needed a three way merge, and `diff3 -m mine base theirs` is
|
||||
the obvious Unix answer. It is the wrong one here.
|
||||
|
||||
```
|
||||
base: - [ ] a mine: - [x] a theirs: - [x] a
|
||||
$ diff3 -m mine base theirs
|
||||
<<<<<<< base
|
||||
- [ ] a
|
||||
=======
|
||||
- [x] a
|
||||
>>>>>>> theirs
|
||||
exit=1
|
||||
```
|
||||
|
||||
Both sides made the *same* edit and it still reports a conflict. Ticking the
|
||||
same box on the phone and the laptop is an entirely ordinary thing to do here,
|
||||
so this would have manufactured conflicts during normal use.
|
||||
|
||||
`git merge-file -p mine base theirs` resolves that case cleanly, and resolves
|
||||
edits to different boxes cleanly, and still conflicts on genuinely competing
|
||||
edits to adjacent lines. That last behaviour matches what the Android merge
|
||||
does, so the two clients agree about what a conflict is, which matters more than
|
||||
either being clever.
|
||||
|
||||
## A merge is only as good as the base it merges from
|
||||
|
||||
The laptop client merged correctly and then failed to record the result as the
|
||||
new base. One sync later the base was a version behind, and an edit that had
|
||||
already been merged in looked like a fresh change from both sides, which
|
||||
produced a conflict out of nothing.
|
||||
|
||||
The symptom appeared far from the cause: the false conflict was reported on a
|
||||
line neither side had touched in that round. The rule the code now follows is
|
||||
that any point where local and server are known to agree records that agreement,
|
||||
whether the agreement came from adopting, from merging to something identical to
|
||||
the server, or from a successful push.
|
||||
|
||||
Worth stating plainly because it applies to both clients: a three way merge is
|
||||
only correct if the base really is the last common ancestor. A stale base does
|
||||
not fail loudly, it silently invents conflicts.
|
||||
|
||||
## Reconciliation, phase 0, completed 2026-09-09
|
||||
|
||||
| File | Resolution |
|
||||
|
||||
+79
-49
@@ -1,14 +1,20 @@
|
||||
#!/bin/zsh
|
||||
# Open today's note, syncing it through WebDAV around the edit.
|
||||
# Open today's note, reconciling it with the server around the edit.
|
||||
#
|
||||
# The server rolls the note forward at 00:01, so normally this just fetches a
|
||||
# file that already exists. Everything here is arranged so that losing the
|
||||
# network degrades to editing the local copy, never to losing an edit.
|
||||
# The server rolls the note forward at 00:01, so normally this fetches a file
|
||||
# that already exists. Everything is arranged so that losing the network
|
||||
# degrades to editing the local copy, never to losing an edit.
|
||||
#
|
||||
# Alongside the note, a pristine copy of the text last agreed with the server is
|
||||
# kept, with its ETag. Without it there is no way to tell "I have unsaved edits"
|
||||
# from "I am simply behind", and the second case gets misreported as the first
|
||||
# every time the phone pushes.
|
||||
|
||||
emulate -L zsh
|
||||
set -o errexit -o nounset -o pipefail
|
||||
|
||||
NOTES=${TODO_NOTES:-$HOME/docs/notes/todo}
|
||||
STATE=${TODO_STATE:-${XDG_STATE_HOME:-$HOME/.local/state}/todo}
|
||||
REMOTE=${TODO_REMOTE:-https://files.rcjohnstone.com/docs/notes/todo}
|
||||
DAV_USER=${TODO_USER:-connor}
|
||||
RBW_ENTRY=${TODO_RBW_ENTRY:-SFTPGo}
|
||||
@@ -16,16 +22,18 @@ PRUNE=${TODO_PRUNE:-$HOME/.local/lib/todo/prune.py}
|
||||
# zshenv exports EDITOR on every invocation, including this script's own, so
|
||||
# `EDITOR=x todo` cannot work. TODO_EDITOR is the override that does.
|
||||
EDIT=${TODO_EDITOR:-${EDITOR:-nvim}}
|
||||
AGENT=todo-cli/1.0
|
||||
AGENT=todo-cli/2.0
|
||||
|
||||
today=$(date --iso-8601)
|
||||
current=$NOTES/$today.md
|
||||
base=$STATE/base/$today.md
|
||||
base_etag=$STATE/base/$today.etag
|
||||
|
||||
mkdir -p $NOTES
|
||||
mkdir -p $NOTES $STATE/base
|
||||
work=$(mktemp -d); trap 'rm -rf $work' EXIT
|
||||
|
||||
note() { print -u2 -- "todo: $*" }
|
||||
die() { print -u2 -- "todo: $*"; exit 1 }
|
||||
note() { print -u2 -- "todo: $*" }
|
||||
die() { print -u2 -- "todo: $*"; exit 1 }
|
||||
|
||||
# Password stays in a variable and reaches curl over stdin. Passing it as -u
|
||||
# would put it in argv, where any other user's `ps` can read it.
|
||||
@@ -42,26 +50,60 @@ dav() {
|
||||
|
||||
etag_of() { grep -i '^etag:' $1 2>/dev/null | tr -d '\r' | awk '{print $2}' }
|
||||
|
||||
adopt() { # text-file etag
|
||||
[[ $1 == $current ]] || cp $1 $current
|
||||
cp $1 $base
|
||||
print -r -- ${2:-} > $base_etag
|
||||
}
|
||||
|
||||
set_aside() { # text-file -> prints the filename used
|
||||
local name=$today.sync-conflict-$(date +%Y%m%d-%H%M%S)-${HOST:u}.md
|
||||
cp $1 $NOTES/$name
|
||||
dav -X PUT --data-binary @$1 -o /dev/null $REMOTE/$name >/dev/null 2>&1 || true
|
||||
print -r -- $name
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- pull
|
||||
|
||||
base_etag=""
|
||||
have_remote=0
|
||||
remote_hash=""
|
||||
|
||||
if [[ -z $dav_pass ]]; then
|
||||
note "no credential from rbw, working offline"
|
||||
else
|
||||
code=$(dav -o $work/pull -D $work/pull.h -w '%{http_code}' \
|
||||
$REMOTE/$today.md) || code=000
|
||||
code=$(dav -o $work/theirs -D $work/h -w '%{http_code}' $REMOTE/$today.md) || code=000
|
||||
case $code in
|
||||
200)
|
||||
base_etag=$(etag_of $work/pull.h)
|
||||
have_remote=1
|
||||
if [[ -e $current ]] && ! cmp -s $current $work/pull; then
|
||||
note "local and remote both changed since last sync, keeping local"
|
||||
note "remote copy saved alongside it for comparison"
|
||||
cp $work/pull $NOTES/$today.remote-$(date +%H%M%S).md
|
||||
their_etag=$(etag_of $work/h)
|
||||
remote_hash=$(md5sum < $work/theirs)
|
||||
if [[ ! -e $current ]]; then
|
||||
adopt $work/theirs $their_etag
|
||||
elif [[ ! -e $base ]]; then
|
||||
# No record of a previous agreement, so nothing to merge from.
|
||||
if cmp -s $current $work/theirs; then
|
||||
adopt $work/theirs $their_etag
|
||||
else
|
||||
note "no sync history, yours saved as $(set_aside $current)"
|
||||
adopt $work/theirs $their_etag
|
||||
fi
|
||||
elif [[ -e $base_etag && -n $their_etag && $their_etag == $(<$base_etag) ]]; then
|
||||
: # server has not moved since the last agreement
|
||||
elif git merge-file -p $current $base $work/theirs > $work/merged 2>/dev/null; then
|
||||
# git rather than diff3. diff3 calls it a conflict even when
|
||||
# both sides made the same change, and both devices ticking the
|
||||
# same box is an ordinary thing to happen here.
|
||||
cp $work/merged $current
|
||||
if cmp -s $current $work/theirs; then
|
||||
# Nothing local to contribute, so this is the new agreement.
|
||||
adopt $work/theirs $their_etag
|
||||
else
|
||||
note "merged in changes from the server"
|
||||
fi
|
||||
else
|
||||
cp $work/pull $current
|
||||
# Both sides changed the same lines. The server's copy becomes
|
||||
# the note and the local one goes beside it, which is the
|
||||
# convention already in this directory.
|
||||
note "conflict, yours saved as $(set_aside $current)"
|
||||
adopt $work/theirs $their_etag
|
||||
fi
|
||||
;;
|
||||
404)
|
||||
@@ -74,12 +116,8 @@ else
|
||||
# earn a four hour ban that no whitelist covers.
|
||||
die "credentials rejected. fix the $RBW_ENTRY entry, do not re-run blindly"
|
||||
;;
|
||||
000)
|
||||
note "server unreachable, working offline"
|
||||
;;
|
||||
*)
|
||||
note "server returned $code, working offline"
|
||||
;;
|
||||
000) note "server unreachable, working offline" ;;
|
||||
*) note "server returned $code, working offline" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
@@ -88,7 +126,7 @@ fi
|
||||
if [[ ! -e $current ]]; then
|
||||
# Match the date glob exactly. A bare `ls | tail -1` can select a
|
||||
# .sync-conflict- sibling, which is how divergent notes spread before.
|
||||
previous=(${~NOTES}/[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9].md(N))
|
||||
previous=($NOTES/[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9].md(N))
|
||||
if (( ${#previous} )); then
|
||||
src=${previous[-1]}
|
||||
if [[ -x $PRUNE ]]; then
|
||||
@@ -97,8 +135,8 @@ if [[ ! -e $current ]]; then
|
||||
note "prune not found at $PRUNE, carrying the note forward unpruned"
|
||||
cp $src $work/new
|
||||
fi
|
||||
# Only line 1. A global replace would rewrite any date in the body,
|
||||
# so "meeting moved to 2026-09-08" would silently become today.
|
||||
# Only line 1. A global replace would rewrite any date in the body, so
|
||||
# "meeting moved to 2026-09-08" would silently become today.
|
||||
sed -i "1s/.*/# $today/" $work/new
|
||||
cp $work/new $current
|
||||
note "created $today.md from ${src:t}"
|
||||
@@ -108,34 +146,26 @@ if [[ ! -e $current ]]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
before=$(md5sum < $current)
|
||||
|
||||
# ---------------------------------------------------------------- edit
|
||||
|
||||
$EDIT $current
|
||||
|
||||
# ---------------------------------------------------------------- push
|
||||
|
||||
[[ $(md5sum < $current) == $before ]] && { note "unchanged, nothing to push"; exit 0 }
|
||||
[[ $(md5sum < $current) == $remote_hash ]] && { note "in step with the server"; exit 0 }
|
||||
[[ -n $dav_pass ]] || { note "no credential, leaving changes local"; exit 0 }
|
||||
|
||||
# SFTPGo returns ETags but ignores If-Match on PUT, so the precondition has to
|
||||
# be checked here instead. The race window is milliseconds wide for one person.
|
||||
if (( have_remote )); then
|
||||
dav -I -D $work/head.h -o /dev/null $REMOTE/$today.md >/dev/null 2>&1 || true
|
||||
now_etag=$(etag_of $work/head.h)
|
||||
if [[ -n $now_etag && -n $base_etag && $now_etag != $base_etag ]]; then
|
||||
sibling=$NOTES/$today.sync-conflict-$(date +%Y%m%d-%H%M%S)-${HOST:u}.md
|
||||
cp $current $sibling
|
||||
die "remote changed while you were editing. yours saved as ${sibling:t}, nothing overwritten"
|
||||
fi
|
||||
fi
|
||||
|
||||
code=$(dav -X PUT --data-binary @$current -o /dev/null -w '%{http_code}' \
|
||||
$REMOTE/$today.md) || code=000
|
||||
code=$(dav -X PUT --data-binary @$current -o /dev/null -w '%{http_code}' $REMOTE/$today.md) ||
|
||||
code=000
|
||||
case $code in
|
||||
200|201|204) note "pushed $today.md" ;;
|
||||
401) die "credentials rejected on push. changes are safe locally" ;;
|
||||
000) note "push failed, server unreachable. changes are safe locally" ;;
|
||||
*) note "push returned $code. changes are safe locally" ;;
|
||||
200|201|204)
|
||||
# The ETag a PUT returns is not the file's own, so it is read back
|
||||
# rather than believed. See FINDINGS.md.
|
||||
dav -I -D $work/ph -o /dev/null $REMOTE/$today.md >/dev/null 2>&1 || true
|
||||
adopt $current "$(etag_of $work/ph)"
|
||||
note "pushed $today.md"
|
||||
;;
|
||||
401) die "credentials rejected on push. changes are safe locally" ;;
|
||||
000) note "push failed, server unreachable. changes are safe locally" ;;
|
||||
*) note "push returned $code. changes are safe locally" ;;
|
||||
esac
|
||||
|
||||
Reference in New Issue
Block a user