aboutsummaryrefslogtreecommitdiff
path: root/docs/issue-conflict-handling-spec.org
blob: 09acaf630e9a52797fff1774071a57e69b50cb64 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
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.)