From 34b4e5ecd8468129cb97e870987fc8bb8d27d6e2 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Wed, 27 May 2026 16:52:07 -0500 Subject: fix(save): detect comment edits with a rendered-Org hash, not the md round-trip A comment whose markdown doesn't survive md->org->md (a heading, single-asterisk italics) was flagged changed on every save, because the dirty scan compared org-to-md of the rendered body against the stored markdown hash. When the comment was authored by someone else, that surfaced as "1 read-only comment skipped" on every save even though nothing had been edited. Descriptions already dodge this: 9424b84 gave them LINEAR-DESC-ORG-SHA256 and compared Org-to-Org. I did the same for comments. pearl--format-comment now writes LINEAR-COMMENT-ORG-SHA256 (the rendered body hash), and a new pearl--comment-dirty-p compares the current body against it, falling back to the old markdown hash for comments rendered before the baseline existed. Both detection sites use it: the save scan and the edit-current-comment no-op check. The markdown hash stays for the remote-conflict gate. --- pearl.el | 62 +++++++++++++++++++++++++++++++++++--------------------------- 1 file changed, 35 insertions(+), 27 deletions(-) (limited to 'pearl.el') diff --git a/pearl.el b/pearl.el index 18c65c2..3ade6dc 100644 --- a/pearl.el +++ b/pearl.el @@ -1762,6 +1762,7 @@ A null author renders as `(unknown)'." (format ":LINEAR-COMMENT-ID: %s\n" (or (plist-get comment :id) "")) (format ":LINEAR-COMMENT-AUTHOR-ID: %s\n" (or (plist-get comment :author-id) "")) (format ":LINEAR-COMMENT-SHA256: %s\n" (secure-hash 'sha256 raw-body)) + (format ":LINEAR-COMMENT-ORG-SHA256: %s\n" (secure-hash 'sha256 (string-trim body))) ":END:\n" (if (string-empty-p body) "" (concat body "\n"))))) @@ -3283,28 +3284,37 @@ track correctly across the in-place inserts and deletes the merge performs." ;;; Dirty detection (the save model's local scanners) +(defun pearl--comment-dirty-p () + "Return non-nil when the comment body at point differs from its last fetch. +An Org-to-Org comparison against `LINEAR-COMMENT-ORG-SHA256' (the rendered body +as fetched), so a comment whose markdown does not survive md->org->md (a heading +or single-asterisk emphasis) is not mistaken for a local edit -- the same fix +`pearl--subtree-dirty-p' applies to descriptions. Comments rendered before that +baseline existed fall back to the older markdown-round-trip comparison against +`LINEAR-COMMENT-SHA256'; they migrate on the next re-render." + (let ((org-hash (org-entry-get nil "LINEAR-COMMENT-ORG-SHA256"))) + (if org-hash + (not (string= (secure-hash 'sha256 (pearl--entry-body-at-point)) org-hash)) + (let ((stored (or (org-entry-get nil "LINEAR-COMMENT-SHA256") ""))) + (not (string= (secure-hash 'sha256 (pearl--org-to-md (pearl--entry-body-at-point))) + stored)))))) + (defun pearl--changed-comment-candidates () "Return comments under the issue at point whose body changed since fetch. -Each is a plist (:comment-id :author-id :marker). Local only: compares -`secure-hash' of `pearl--org-to-md' of each comment body to its stored -`LINEAR-COMMENT-SHA256' (taken over the markdown Linear stored, so the -comparison must round-trip through markdown, as `pearl-edit-current-comment' -does). A candidate only means the body differs; ownership is decided later." +Each is a plist (:comment-id :author-id :marker). Local only, via +`pearl--comment-dirty-p'. A candidate only means the body differs; ownership is +decided later." (let (candidates) (save-excursion (pearl--goto-heading-or-error) (let ((issue-end (save-excursion (org-end-of-subtree t t) (point)))) (while (and (outline-next-heading) (< (point) issue-end)) (let ((cid (org-entry-get nil "LINEAR-COMMENT-ID"))) - (when (and cid (not (string-empty-p cid))) - (let ((stored (or (org-entry-get nil "LINEAR-COMMENT-SHA256") "")) - (local (secure-hash 'sha256 - (pearl--org-to-md (pearl--entry-body-at-point))))) - (unless (string= local stored) - (push (list :comment-id cid - :author-id (org-entry-get nil "LINEAR-COMMENT-AUTHOR-ID") - :marker (point-marker)) - candidates)))))))) + (when (and cid (not (string-empty-p cid)) (pearl--comment-dirty-p)) + (push (list :comment-id cid + :author-id (org-entry-get nil "LINEAR-COMMENT-AUTHOR-ID") + :marker (point-marker)) + candidates)))))) (nreverse candidates))) (defun pearl--priority-number-at-point () @@ -4728,23 +4738,21 @@ reported (refresh to reconcile)." (save-excursion (pearl--goto-heading-or-error "Not on a Linear comment") (let ((comment-id (org-entry-get nil "LINEAR-COMMENT-ID")) - (stored (org-entry-get nil "LINEAR-COMMENT-SHA256")) (marker (point-marker)) (buf (current-buffer))) (unless comment-id (user-error "Not on a Linear comment")) - ;; No-op fast path before resolving the viewer: an unchanged comment - ;; costs no viewer lookup. `pearl--entry-body-at-point' reads the text - ;; after the drawer and before the first child heading -- a comment - ;; subtree has that shape, so it serves the comment body unchanged. - (let ((local-md (pearl--org-to-md (pearl--entry-body-at-point)))) - (if (string= (secure-hash 'sha256 local-md) (or stored "")) - (message "No comment changes to sync") - (pearl--viewer-async - (lambda (viewer) - (pearl--save-comment-field - marker (and viewer (plist-get viewer :id)) - (lambda (outcome) (pearl--report-save-outcome outcome buf)))))))))) + ;; No-op fast path before resolving the viewer: an unchanged comment costs + ;; no viewer lookup. The dirty check is Org-to-Org (see + ;; `pearl--comment-dirty-p'), so a comment whose markdown does not survive + ;; the round-trip is not falsely seen as edited. + (if (not (pearl--comment-dirty-p)) + (message "No comment changes to sync") + (pearl--viewer-async + (lambda (viewer) + (pearl--save-comment-field + marker (and viewer (plist-get viewer :id)) + (lambda (outcome) (pearl--report-save-outcome outcome buf))))))))) ;;;###autoload (defun pearl-delete-current-comment () -- cgit v1.2.3