aboutsummaryrefslogtreecommitdiff
path: root/docs/issue-conflict-handling-spec.org
diff options
context:
space:
mode:
Diffstat (limited to 'docs/issue-conflict-handling-spec.org')
-rw-r--r--docs/issue-conflict-handling-spec.org74
1 files changed, 74 insertions, 0 deletions
diff --git a/docs/issue-conflict-handling-spec.org b/docs/issue-conflict-handling-spec.org
new file mode 100644
index 0000000..09acaf6
--- /dev/null
+++ b/docs/issue-conflict-handling-spec.org
@@ -0,0 +1,74 @@
+#+TITLE: pearl — Interactive Conflict Handling Spec
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-05-24
+#+STARTUP: showall
+
+* Status
+
+*IMPLEMENTED (2026-05-24).* Shipped in two increments: the use-local / use-remote / cancel core, then the smerge rewrite-in-buffer path and the refresh hardening. The open questions are resolved; see "Decisions" at the end.
+
+Companion to [[file:issue-representation-spec.org][issue-representation-spec.org]] (description/title editing) and [[file:issue-comment-editing-spec.org][issue-comment-editing-spec.org]] (comment editing). All three share the conflict gate this doc proposes to extend.
+
+* Problem
+
+v1's conflict handling is detect / refuse / message: when a description, title, or comment changed both locally and on Linear since the last fetch, the push is refused and a message tells the user to refresh. That protects the remote, but it leaves the user stuck. The only way forward is a manual refresh — which replaces the subtree and *discards the local edit*. So the safe-by-default behavior has a data-loss trap one keystroke away, and no in-Emacs path to actually reconcile the two versions.
+
+Craig's direction (2026-05-24): keep it simple — offer use-local, use-remote, or rewrite-in-an-Emacs-buffer. Error messages must be descriptive. And there must be a way through that never silently discards the user's input, because that counts as data loss.
+
+* Current state
+
+- =pearl--sync-decision= (=pearl.el:~1682=) returns =:noop= / =:push= / =:conflict= from the three-way hash compare (local-rendered vs last-fetched vs current-remote).
+- =pearl-sync-current-issue=, =-sync-current-issue-title=, and =pearl-edit-current-comment= all =pcase= on that and, for =:conflict=, just =message= and stop.
+- =refresh-current-issue= has a dirty-buffer guard that refuses to refresh when the body has unpushed edits — so a refresh can't clobber silently *today*, but it also can't help resolve; the user has to throw away their edit to move on.
+
+* Proposed design
+
+On =:conflict=, instead of only refusing, prompt the user to choose a resolution. One shared helper drives all three call sites (description, title, comment) so the behavior is identical everywhere.
+
+** The resolution prompt
+
+=completing-read= (or a transient) with three choices, each with a descriptive label:
+
+1. *Use local* — push my version, overwriting the remote. Advances the stored hash to the local text.
+2. *Use remote* — discard my local edit and take Linear's current version. **Guarded against data loss** (see below): the local text is stashed before it's replaced.
+3. *Rewrite in a buffer* — open a reconciliation buffer showing both versions; the user produces the merged text and pushes that.
+
+A fourth implicit option is always cancel (=C-g=) — leaves everything untouched, same as today's refuse.
+
+** No data loss — the hard requirement
+
+"Use remote" and "rewrite" both risk throwing away what the user typed. Before either path replaces the local text, stash it so it's always recoverable:
+
+- Push the local version onto the =kill-ring= (so =yank= brings it back), and
+- write it to a dedicated =*pearl-conflict-backup*= buffer with a heading naming the issue/field and timestamp.
+
+The stash happens unconditionally on any destructive resolution. The message after "use remote" says where the old text went ("your local version is on the kill-ring and in =*pearl-conflict-backup*=").
+
+** The rewrite-in-a-buffer flow
+
+Open a reconciliation buffer prefilled so the user can see and edit both sides. Chosen mechanism (decision 1): *smerge*. Write the two versions as a =<<<<<<< LOCAL / ======= / >>>>>>> REMOTE= conflict and drop the user into =smerge-mode=, so =smerge-keep-current= / =-other= / =-all= and the rest work without custom keys. A short banner names the push/abort keys. (Considered and rejected: a plain two-section buffer — simpler but reinvents conflict navigation; and =ediff= — too heavy for a one-field reconcile.)
+
+On finish, the reconciled text (markers resolved) is pushed via the same =--update-*= path, and the stored hash advances to it.
+
+** Descriptive errors
+
+The conflict prompt and messages name specifics: the field (description / title / comment), the issue identifier, that both sides changed since the last fetch, and the remote's =updatedAt= so the user knows how stale their copy is. No bare "conflict detected".
+
+* Proposed v1 decisions (this feature)
+
+1. One shared resolution helper across description, title, and comment.
+2. Three resolutions plus cancel: use-local, use-remote, rewrite-in-buffer.
+3. Any destructive resolution stashes the local text to the kill-ring *and* a backup buffer first — never discard input.
+4. Messages and the prompt are field- and issue-specific.
+
+* vNext / out of scope
+
+- Field-level 3-way auto-merge (only the changed lines).
+- Conflict resolution for the drawer fields (state/priority/assignee/labels) — those are command-set, not free-text, so they don't have the same merge problem.
+
+* Decisions (Craig, 2026-05-24)
+
+1. *Rewrite-buffer mechanism*: =smerge=. Write the two versions as a =<<<<<<< / ======= / >>>>>>>= conflict and drop the user into =smerge-mode=; the =smerge-keep-*= commands work out of the box and the UX matches git muscle memory. No heavy dependency.
+2. *Stash location*: kill-ring + a =*pearl-conflict-backup*= buffer. In-memory recovery (yank, or read the named buffer); no file-backup layer in v1.
+3. *Default resolution on RET*: cancel. A bare =RET= at the prompt leaves everything untouched, the same as today's refuse — the safest default.
+4. *"Use remote" guard scope*: yes. =refresh-current-issue= adopts the same stash-before-replace guarantee, so no refresh path can lose an unpushed edit. (The merge refresh already keeps dirty subtrees rather than overwriting; this hardens the single-issue refresh, which today refuses on a dirty body — it will stash then proceed instead.)