-
Notifications
You must be signed in to change notification settings - Fork 9
Expand file tree
/
Copy pathmajutsu-ediff.el
More file actions
807 lines (726 loc) · 31.9 KB
/
majutsu-ediff.el
File metadata and controls
807 lines (726 loc) · 31.9 KB
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
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
;;; majutsu-ediff.el --- Ediff extension for Majutsu -*- lexical-binding: t; -*-
;; Copyright (C) 2026 0WD0
;; Author: 0WD0 <wd.1105848296@gmail.com>
;; Maintainer: 0WD0 <wd.1105848296@gmail.com>
;; Keywords: tools, vc
;; URL: https://github.com/0WD0/majutsu
;; SPDX-License-Identifier: GPL-3.0-or-later
;; Portions of Ediff orchestration are adapted from:
;; - Magit `lisp/magit-ediff.el` (commit c800f79c2061621fde847f6a53129eca0e8da728)
;; Copyright (C) 2008-2026 The Magit Project Contributors
;; - GNU Emacs `lisp/vc/ediff-util.el` (commit b37711a25f78a915a10245a6330c3b2b4434b2e5)
;; Copyright (C) 1994-2026 Free Software Foundation, Inc.
;;; Commentary:
;; This library provides Ediff support for Majutsu.
;;; Code:
(require 'cl-lib)
(require 'ediff)
(require 'transient)
(require 'magit-section)
(require 'majutsu-base)
(require 'majutsu-jj)
(require 'majutsu-edit)
(require 'majutsu-process)
(require 'majutsu-file)
(require 'majutsu-conflict)
(require 'majutsu-selection)
(declare-function majutsu-diff-visit-file "majutsu-diff" (&optional force-workspace))
(defvar majutsu-buffer-diff-range)
(defvar ediff-after-quit-hook-internal)
(defvar ediff-buffer-C)
(defvar ediff-combination-pattern)
(defvar ediff-default-variant)
(defvar ediff-merge-store-file)
(defconst majutsu-ediff--merge-tool-name "majutsu_ediff_merge"
"Jujutsu merge-tools entry used by `majutsu-ediff-resolve'.")
(defconst majutsu-ediff--control-packet-prefix "MAJUTSU-EDIFF"
"Prefix used by Majutsu Ediff control packets in jj process output.")
(defconst majutsu-ediff--sleeping-input-ready-timeout 2.0
"Seconds to wait for sleeping-editor temp inputs to become accessible.")
(defconst majutsu-ediff--sleeping-input-ready-interval 0.05
"Polling interval while waiting for sleeping-editor temp inputs.")
;;; Options
(defgroup majutsu-ediff nil
"Ediff support for Majutsu."
:group 'majutsu)
(defcustom majutsu-ediff-quit-hook
(list #'majutsu-ediff-cleanup-auxiliary-buffers
#'majutsu-ediff-restore-previous-winconf)
"Hooks to run after finishing Ediff, when that was invoked using Majutsu."
:group 'majutsu-ediff
:type 'hook)
;;; Variables
(defvar majutsu-ediff-previous-winconf nil
"Window configuration before starting Ediff.")
;;; Buffer Management
(defmacro majutsu-ediff-buffers (a b &optional c setup quit file)
"Run Ediff on two or three buffers.
A, B and C have the form (GET-BUFFER CREATE-BUFFER). If GET-BUFFER
returns a non-nil value, that buffer is used and not killed when exiting.
Otherwise CREATE-BUFFER must return a buffer and that is killed on exit.
SETUP is called after Ediff setup. QUIT is added to quit hook.
If FILE is non-nil, perform a merge with result written to FILE."
(let (get make kill (char ?A))
(dolist (spec (list a b c))
(if (not spec)
(push nil make)
(pcase-let ((`(,g ,m) spec))
(let ((b (intern (format "buf%c" char))))
(push `(,b ,g) get)
(push `(or ,b ,m) make)
(push `(unless ,b
(let ((var ,(if (and file (= char ?C))
'ediff-ancestor-buffer
(intern (format "ediff-buffer-%c" char)))))
(ediff-kill-buffer-carefully var)))
kill))
(cl-incf char))))
(setq get (nreverse get))
(setq make (nreverse make))
(setq kill (nreverse kill))
(let ((mconf (gensym "conf"))
(mfile (gensym "file")))
`(majutsu-with-toplevel
(let ((,mconf (current-window-configuration))
(,mfile ,file)
,@get)
(ediff-buffers-internal
,@make
(list ,@(and setup (list setup))
(lambda ()
(setq-local ediff-quit-merge-hook nil)
(setq-local ediff-quit-hook
(list
,@(and quit (list quit))
(lambda ()
,@kill
(let ((majutsu-ediff-previous-winconf ,mconf))
(run-hooks 'majutsu-ediff-quit-hook)))))))
(pcase (list ,(and c t) (and ,mfile t))
('(nil nil) 'ediff-buffers)
('(nil t) 'ediff-merge-buffers)
('(t nil) 'ediff-buffers3)
('(t t) 'ediff-merge-buffers-with-ancestor))
,mfile))))))
(defun majutsu-ediff-cleanup-auxiliary-buffers ()
"Kill Ediff control and auxiliary buffers."
(let* ((ctl ediff-control-buffer))
(ediff-kill-buffer-carefully ediff-diff-buffer)
(ediff-kill-buffer-carefully ediff-custom-diff-buffer)
(ediff-kill-buffer-carefully ediff-fine-diff-buffer)
(ediff-kill-buffer-carefully ediff-tmp-buffer)
(ediff-kill-buffer-carefully ediff-error-buffer)
(ediff-kill-buffer-carefully ediff-msg-buffer)
(ediff-kill-buffer-carefully ediff-debug-buffer)
(when (boundp 'ediff-patch-diagnostics)
(ediff-kill-buffer-carefully ediff-patch-diagnostics))
(ediff-kill-buffer-carefully ctl)))
(defun majutsu-ediff-restore-previous-winconf ()
"Restore window configuration saved before Ediff."
(when (window-configuration-p majutsu-ediff-previous-winconf)
(set-window-configuration majutsu-ediff-previous-winconf)))
;;; Helpers
(defun majutsu-ediff--get-revision-buffer (rev file)
"Return existing buffer for REV:FILE or nil."
(get-buffer (majutsu-file--buffer-name rev file)))
(defun majutsu-ediff--find-file-noselect (rev file)
"Return buffer visiting FILE from REV."
(majutsu-find-file-noselect rev file))
(defun majutsu-ediff--parse-conflict-line (line)
"Parse plain-text `jj resolve --list' LINE.
Return a plist with `:file' and `:sides', or nil when LINE is not recognized.
Internal whitespace in file paths is preserved; only the padding before the
conflict description is stripped."
(when (and line
(string-match
"[ \t]+\\([0-9]+\\)-sided conflict\\(?: including .*\\)?\\(?:\\r\\)?\\'"
line))
(list :file (string-trim-right (substring line 0 (match-beginning 0)))
:sides (string-to-number (match-string 1 line)))))
(defun majutsu-ediff--list-conflicted-files (&optional rev)
"Return list of conflicted files at REV (default @)."
(let* ((default-directory (majutsu-file--root))
(lines (majutsu-jj-lines "resolve" "--list" "-r" (or rev "@"))))
(mapcar (lambda (line)
(or (plist-get (majutsu-ediff--parse-conflict-line line) :file)
line))
lines)))
(defun majutsu-ediff--read-conflicted-file (&optional rev)
"Prompt for a conflicted file at REV."
(let ((files (majutsu-ediff--list-conflicted-files rev)))
(cond
((null files)
(user-error "No conflicts found at revision %s" (or rev "@")))
((= (length files) 1)
(car files))
(t
(completing-read "Resolve conflicts in: " files nil t)))))
(defun majutsu-ediff--resolve-file-dwim (&optional file)
"Return conflicted FILE for resolve workflow.
If FILE is nil and point is on a commit section, prompt conflicts in that
revision; otherwise prompt from working copy conflicts."
(or file
(majutsu-ediff--read-conflicted-file
(majutsu-revision-at-point))))
(defun majutsu-ediff--working-copy-revision-p (rev)
"Return non-nil when REV resolves to the working copy change.
REV may be any revset accepted by jj."
(let* ((working (majutsu-file--resolve-single-rev-info "@"))
(target (majutsu-file--resolve-single-rev-info (or rev "@"))))
(and working target
(equal (plist-get working :change-id)
(plist-get target :change-id)))))
(defun majutsu-ediff--resolve-target-buffer (rev file)
"Return the conflict target buffer for REV and FILE.
When REV is the working copy, visit FILE from the filesystem. Otherwise
visit FILE as a blob buffer at REV."
(if (majutsu-ediff--working-copy-revision-p rev)
(find-file-noselect
(expand-file-name file (majutsu-file--root)))
(majutsu-find-file-noselect rev file)))
;;; Commands
;;;###autoload
(defun majutsu-ediff-compare (from to &optional file)
"Compare FILE between FROM and TO revisions using Ediff.
If FILE is nil, prompt for one."
(interactive
(let* ((from (majutsu-read-revset "Compare from" "@-"))
(to (majutsu-read-revset "Compare to" "@")))
(list from to nil)))
(let ((file (or file (majutsu-jj-read-diff-file from to))))
(majutsu-ediff-buffers
((majutsu-ediff--get-revision-buffer from file)
(majutsu-ediff--find-file-noselect from file))
((majutsu-ediff--get-revision-buffer to file)
(majutsu-ediff--find-file-noselect to file)))))
;;;###autoload
(defun majutsu-ediff-show-revision (rev &optional file)
"Show changes in REV using Ediff (parent vs rev).
If FILE is nil, prompt for one."
(interactive
(list (majutsu-read-revset "Show revision" "@")))
(let* ((parent (concat rev "-"))
(file (or file (majutsu-jj-read-diff-file parent rev))))
(majutsu-ediff-compare parent rev file)))
(defun majutsu-ediff--current-range ()
"Return the current diff range from transient or buffer."
(majutsu-jj--parse-diff-range
(if (eq transient-current-command 'majutsu-ediff)
(transient-args 'majutsu-ediff)
majutsu-buffer-diff-range)))
(defun majutsu-ediff--conflict-side-count (rev file)
"Return the sidedness for FILE conflict at REV.
When sidedness cannot be parsed, return 0."
(let* ((default-directory (majutsu-file--root))
(line (car (majutsu-jj-lines "resolve" "--list" "-r" rev "--" file))))
(or (plist-get (majutsu-ediff--parse-conflict-line line) :sides)
0)))
(defun majutsu-ediff--build-resolve-args (rev file merge-editor-config)
"Build `jj resolve' arguments for REV and FILE.
MERGE-EDITOR-CONFIG is a TOML config string or list of config strings."
(let ((configs (if (listp merge-editor-config)
merge-editor-config
(list merge-editor-config))))
(append
(list "resolve")
(apply #'append
(mapcar (lambda (config)
(list "--config" config))
configs))
(list "-r" (or rev "@"))
(when file
(list "--" file)))))
(defun majutsu-ediff--toml-string-config (key value)
"Build KEY=VALUE TOML config where VALUE is a basic string."
(format "%s=\"%s\"" key (majutsu-jj--toml-escape value)))
(defun majutsu-ediff--merge-editor-config ()
"Return ui.merge-editor config for Majutsu Ediff control callbacks."
(let* ((merge-args (list "-c" (majutsu-ediff--control-merge-script)
"majutsu-ediff"
"$left" "$base" "$right" "$output" "$marker_length"))
(tool-key (format "merge-tools.%s" majutsu-ediff--merge-tool-name)))
(list
(majutsu-ediff--toml-string-config "ui.merge-editor" majutsu-ediff--merge-tool-name)
(majutsu-ediff--toml-string-config (format "%s.program" tool-key) "sh")
(majutsu-jj--toml-array-config (format "%s.merge-args" tool-key) merge-args)
(format "%s.merge-tool-edits-conflict-markers=true" tool-key)
(majutsu-ediff--toml-string-config (format "%s.conflict-marker-style" tool-key) "git"))))
(defun majutsu-ediff--run-resolve (rev file)
"Run `jj resolve` for REV and FILE with Majutsu Ediff config."
(let* ((merge-editor-cmd
(majutsu-ediff--merge-editor-config))
(args (majutsu-ediff--build-resolve-args rev file merge-editor-cmd)))
;; Use async to avoid blocking Emacs while jj waits for merge completion.
(apply #'majutsu-run-jj-async args)))
(defun majutsu-ediff--diff-editor-config (&optional file)
"Return ui.diff-editor config for Majutsu Ediff control callbacks.
When FILE is non-nil, pass it as a file hint."
(let* ((command (list "sh" "-c" (majutsu-ediff--control-diff-script)
"majutsu-ediff" "$left" "$right" (or file ""))))
(majutsu-jj--toml-array-config
"ui.diff-editor"
command)))
(defun majutsu-ediff--control-diff-script ()
"Return shell script used for diffedit control packets."
(string-join
(list
;; Keep jj blocked until Emacs signals completion.
"sleep 604800 & sleep_pid=$!"
"printf \"\\nMAJUTSU-EDIFF: $$ DIFF %s\\037%s\\037%s\\n\" \"$1\" \"$2\" \"$3\""
"trap \"kill $sleep_pid; exit 0\" USR1"
"trap \"kill $sleep_pid; exit 1\" USR2"
"wait $sleep_pid")
"; "))
(defun majutsu-ediff--control-merge-script ()
"Return shell script used for resolve merge control packets."
(string-join
(list
;; Keep jj blocked until Emacs signals completion.
"sleep 604800 & sleep_pid=$!"
"printf \"\\nMAJUTSU-EDIFF: $$ MERGE %s\\037%s\\037%s\\037%s\\037%s\\n\" \"$1\" \"$2\" \"$3\" \"$4\" \"$5\""
"trap \"kill $sleep_pid; exit 0\" USR1"
"trap \"kill $sleep_pid; exit 1\" USR2"
"wait $sleep_pid")
"; "))
(defun majutsu-ediff--control-packet-spec (line)
"Parse Majutsu Ediff control packet LINE into a plist spec.
Return nil when LINE is not a valid control packet."
(when (string-match
(format "^%s: \\([0-9]+\\) \\(DIFF\\|MERGE\\) \\(.*\\)\\(?:\\r\\)?$"
majutsu-ediff--control-packet-prefix)
line)
(let* ((pid (match-string 1 line))
(kind (downcase (match-string 2 line)))
(fields (split-string (match-string 3 line) "\x1f" nil)))
(pcase kind
("diff"
(pcase fields
(`(,left ,right ,file)
(let ((spec (list :pid pid
:type 'diff
:left left
:right right)))
(if (string-empty-p file)
spec
(append spec (list :file file)))))
(`(,left ,right)
(list :pid pid :type 'diff :left left :right right))
(_ nil)))
("merge"
(pcase fields
(`(,left ,base ,right ,output ,marker)
(list :pid pid
:type 'merge
:left left
:base base
:right right
:output output
:marker marker))
(_ nil)))
(_ nil)))))
(defun majutsu-ediff--control-required-inputs (spec)
"Return temp input files that should exist before handling control SPEC."
(pcase (plist-get spec :type)
('diff
(when-let* ((file (plist-get spec :file))
(left-dir (majutsu-jj-expand-directory-from-jj
(plist-get spec :left)))
(right-dir (majutsu-jj-expand-directory-from-jj
(plist-get spec :right))))
(list (expand-file-name file left-dir)
(expand-file-name file right-dir))))
('merge
(list (majutsu-jj-expand-filename-from-jj (plist-get spec :left))
(majutsu-jj-expand-filename-from-jj (plist-get spec :base))
(majutsu-jj-expand-filename-from-jj (plist-get spec :right))))
(_ nil)))
(defun majutsu-ediff--wait-for-input-files (files)
"Return non-nil when FILES become accessible before timeout."
(let ((deadline (+ (float-time) majutsu-ediff--sleeping-input-ready-timeout))
ready)
(while (and (not (setq ready
(cl-every (lambda (file)
(condition-case nil
(file-exists-p file)
(error nil)))
files)))
(< (float-time) deadline))
(accept-process-output nil majutsu-ediff--sleeping-input-ready-interval))
ready))
(defun majutsu-ediff--signal-control-session (pid ok)
"Signal PID when control session completes.
When OK is non-nil send USR1, otherwise send USR2."
(process-file "kill" nil nil nil
"-s" (if ok "USR1" "USR2") pid))
(defun majutsu-ediff--run-control-packet (spec directory)
"Handle parsed control SPEC in DIRECTORY and signal completion."
(let ((default-directory (or directory default-directory))
(inputs nil)
(ok nil))
(unwind-protect
(condition-case err
(progn
(setq inputs (majutsu-ediff--control-required-inputs spec))
(unless (majutsu-ediff--wait-for-input-files inputs)
(error "temporary inputs unavailable: %s"
(mapconcat #'identity inputs ", ")))
(pcase (plist-get spec :type)
('diff
(majutsu-ediff-diffedit-file
(plist-get spec :left)
(plist-get spec :right)
(plist-get spec :file)))
('merge
(majutsu-ediff-merge-files
(plist-get spec :left)
(plist-get spec :base)
(plist-get spec :right)
(plist-get spec :output)
(plist-get spec :marker))))
(setq ok t))
((error quit)
(message "Majutsu Ediff control callback failed: %s"
(error-message-string err))))
(condition-case err
(majutsu-ediff--signal-control-session (plist-get spec :pid) ok)
((error quit)
(message "Majutsu Ediff failed to signal editor process: %s"
(error-message-string err)))))))
(defun majutsu-ediff--handle-control-line (process line)
"Parse and schedule Majutsu Ediff control LINE emitted by PROCESS.
Return non-nil when LINE is recognized as a Majutsu Ediff control packet."
(when-let* ((spec (majutsu-ediff--control-packet-spec line)))
(let ((directory (or (and process (process-get process 'default-dir))
default-directory)))
(run-at-time 0 nil #'majutsu-ediff--run-control-packet spec directory))
t))
(defun majutsu-ediff--run-diffedit (jj-args &optional file)
"Run jj diffedit with JJ-ARGS using `majutsu-ediff-diffedit-file'."
(setq file (or file (cadr (member "--" jj-args))))
(unless file
(user-error "Diffedit requires a file target"))
(let* ((root (majutsu--toplevel-safe default-directory))
(default-directory root)
(file (if (file-name-absolute-p file)
(let* ((abs-root (file-name-as-directory (expand-file-name root)))
(abs-file (expand-file-name file)))
(if (string-prefix-p abs-root abs-file)
(file-relative-name abs-file abs-root)
(user-error "Diffedit target outside repository: %s" file)))
file))
(jj-args (majutsu-edit--replace-diffedit-file-arg jj-args file)))
(let ((diff-editor-cmd (majutsu-ediff--diff-editor-config file)))
;; Use async to avoid blocking Emacs while jj waits for diff completion.
(apply #'majutsu-run-jj-async "diffedit" "--config" diff-editor-cmd jj-args))))
(defun majutsu-ediff--cleanup-diffedit-variant-buffers
(left-file right-file left-existing right-existing)
"Persist and close diffedit variant buffers created for this session."
(cl-labels ((force-kill (buffer)
(when (buffer-live-p buffer)
(with-current-buffer buffer
;; with-editor installs a kill guard that raises a user-error.
;; Diffedit cleanup runs after the edit session has already
;; finished, so bypass query hooks to avoid aborting quit flow.
(let ((kill-buffer-query-functions nil))
(kill-buffer buffer))))))
(let* ((left-buffer (get-file-buffer left-file))
(right-buffer (get-file-buffer right-file)))
(when (and (buffer-live-p right-buffer)
(not right-existing))
(with-current-buffer right-buffer
(when (and (buffer-file-name)
(buffer-modified-p)
(file-writable-p (buffer-file-name)))
(save-buffer)))
(force-kill right-buffer))
(when (and (buffer-live-p left-buffer)
(not left-existing))
(force-kill left-buffer)))))
(defun majutsu-ediff--setup-diffedit-quit-hooks
(winconf left-file right-file left-existing right-existing)
"Install local quit hooks for a diffedit Ediff session.
WINCONF is the window configuration saved before launching Ediff."
(add-hook 'ediff-quit-hook
(lambda ()
(let ((majutsu-ediff-previous-winconf winconf))
(run-hooks 'majutsu-ediff-quit-hook)))
t t)
(add-hook 'ediff-after-quit-hook-internal
(lambda ()
(majutsu-ediff--cleanup-diffedit-variant-buffers
left-file right-file left-existing right-existing))
t t)
;; Exit recursive edit only after Ediff has completed its quit cleanup.
(add-hook 'ediff-after-quit-hook-internal
#'majutsu-ediff--exit-recursive-edit t t))
(defun majutsu-ediff--setup-merge-quit-hooks (winconf)
"Install local quit hooks for a merge Ediff session.
WINCONF is the window configuration saved before launching Ediff."
;; Replace Ediff's default merge quit hook so save semantics stay local to
;; Majutsu's `jj resolve` integration.
(setq-local ediff-quit-merge-hook '(majutsu-ediff--quit-merge-session))
(add-hook 'ediff-quit-hook
(lambda ()
(let ((majutsu-ediff-previous-winconf winconf))
(run-hooks 'majutsu-ediff-quit-hook)))
t t)
;; Exit recursive edit only after Ediff has completed its quit cleanup.
(add-hook 'ediff-after-quit-hook-internal
#'majutsu-ediff--exit-recursive-edit t t))
(defun majutsu-ediff--quit-merge-session ()
"Persist merge output if edited, then close merge buffer.
This hook runs in the Ediff control buffer and is intended for `jj resolve'."
(let ((store-file ediff-merge-store-file))
(when (ediff-buffer-live-p ediff-buffer-C)
(when (and (buffer-modified-p ediff-buffer-C)
(stringp store-file)
(> (length store-file) 0)
(majutsu-ediff--confirm-save-merge store-file))
(ediff-with-current-buffer ediff-buffer-C
;; `ediff-merge-store-file' is local to the control buffer, so keep
;; a stable copy before switching to buffer C.
(write-region nil nil store-file nil 'silent)))
(ediff-with-current-buffer ediff-buffer-C
;; Avoid save prompts during cleanup. Output is already persisted above.
(set-buffer-modified-p nil))
(ediff-kill-buffer-carefully ediff-buffer-C))))
(defun majutsu-ediff--confirm-save-merge (store-file)
"Return non-nil when merge result should be saved to STORE-FILE."
(yes-or-no-p (format "Conflict resolution finished; save %s? " store-file)))
(defun majutsu-ediff--merge-marker-length (marker-length)
"Return normalized conflict MARKER-LENGTH for Ediff merge sessions."
(let* ((parsed (if (numberp marker-length)
marker-length
(string-to-number (or marker-length ""))))
(len (if (> parsed 0) parsed 7)))
(max len 7)))
(defun majutsu-ediff--git-combination-pattern (marker-length)
"Return `ediff-combination-pattern' using git-style MARKER-LENGTH."
(list (make-string marker-length ?<)
'A
(make-string marker-length ?|)
'Ancestor
(make-string marker-length ?=)
'B
(make-string marker-length ?>)))
(defun majutsu-ediff--directory-common-files (left right)
"Return common relative file paths between LEFT and RIGHT directories."
(let* ((left (file-name-as-directory (expand-file-name left)))
(right (file-name-as-directory (expand-file-name right)))
(files nil))
(dolist (abs (directory-files-recursively right ".*" nil t))
(when (file-regular-p abs)
(let ((rel (file-relative-name abs right)))
(when (and (not (string= rel "JJ-INSTRUCTIONS"))
(file-exists-p (expand-file-name rel left)))
(push rel files)))))
(sort files #'string<)))
(defun majutsu-ediff--read-directory-file (left right)
"Read a single common file path between LEFT and RIGHT directories."
(let ((files (majutsu-ediff--directory-common-files left right)))
(cond
((null files)
(user-error "No common editable files between %s and %s" left right))
((= (length files) 1)
(car files))
(t
(completing-read "Diffedit file: " files nil t)))))
;;;###autoload
(defun majutsu-ediff-dwim ()
"Context-aware Ediff based on current section."
(interactive)
(magit-section-case
(jj-hunk
(save-excursion
(goto-char (oref (oref it parent) start))
(majutsu-ediff-dwim)))
(jj-file
(let* ((file (oref it value))
(range (majutsu-ediff--current-range)))
(majutsu-ediff-compare (car range) (cdr range) file)))
(jj-commit
(majutsu-ediff-show-revision (substring-no-properties (oref it value))))
(t
(let* ((range (majutsu-ediff--current-range))
(file (majutsu-file-at-point)))
(cond
((and (car range) (cdr range))
(if file
(majutsu-ediff-compare (car range) (cdr range) file)
(majutsu-ediff-compare (car range) (cdr range))))
((car range)
(majutsu-ediff-show-revision (car range)))
(t
(majutsu-ediff-show-revision "@")))))))
;;;###autoload
(defun majutsu-ediff-edit (args)
"Edit one changed file with two-sided Ediff via jj diffedit.
ARGS are transient arguments."
(interactive
(list (when (eq transient-current-command 'majutsu-ediff)
(transient-args 'majutsu-ediff))))
(let* ((range (majutsu-edit--edit-range args))
(from (car range))
(to (cdr range))
(file (majutsu-edit--read-diffedit-file from to))
(jj-args (majutsu-edit--build-diffedit-args from to file)))
(majutsu-ediff--run-diffedit jj-args file)))
;;;###autoload
(defun majutsu-ediff-resolve (&optional file)
"Resolve FILE conflicts using `jj resolve' with Emacs as merge editor.
If FILE is nil, DWIM selects from conflicted files at point revision (commit
section) or the working copy."
(interactive)
(let* ((rev (or (majutsu-revision-at-point) "@"))
(file (majutsu-ediff--resolve-file-dwim file))
(sides (majutsu-ediff--conflict-side-count rev file)))
(if (> sides 2)
(progn
(message "%s has %d sides; using diffedit fallback" file sides)
(majutsu-edit--run-diffedit
(majutsu-edit--build-diffedit-args nil rev file)
file))
(majutsu-ediff--run-resolve rev file))))
;;;###autoload
(defun majutsu-ediff-resolve-with-conflict ()
"Resolve conflicts using `majutsu-conflict-mode'.
When resolving a non-working-copy revision, open the matching blob buffer
at that revision before enabling conflict mode."
(interactive)
(let* ((rev (or (majutsu-revision-at-point) "@"))
(file (majutsu-ediff--resolve-file-dwim))
(buffer (majutsu-ediff--resolve-target-buffer rev file)))
(pop-to-buffer buffer)
(with-current-buffer buffer
(majutsu-conflict-ensure-mode)
(majutsu-conflict-goto-nearest))))
;;;###autoload
(defun majutsu-ediff-diffedit-file (left right &optional file-hint)
"Compare one file from LEFT and RIGHT using Ediff.
This is called by jj diffedit when using Emacs as the diff editor.
Blocks until user finishes editing and quits Ediff."
(interactive "DLeft directory: \nDRight directory: ")
(let* ((left (majutsu-jj-expand-directory-from-jj left))
(right (majutsu-jj-expand-directory-from-jj right))
(file-hint (and file-hint
(not (string-empty-p file-hint))
file-hint))
(file (or file-hint
(majutsu-ediff--read-directory-file left right)))
(left-file (expand-file-name file left))
(right-file (expand-file-name file right))
(left-existing (get-file-buffer left-file))
(right-existing (get-file-buffer right-file))
(winconf (current-window-configuration)))
(ediff-files left-file right-file
(list (lambda ()
(majutsu-ediff--setup-diffedit-quit-hooks
winconf left-file right-file left-existing right-existing))))
(recursive-edit)))
(defun majutsu-ediff--exit-recursive-edit ()
"Exit recursive edit when Ediff quits."
(when (> (recursion-depth) 0)
(exit-recursive-edit)))
;;;###autoload
(defun majutsu-ediff-merge-files (left base right output &optional marker-length)
"Resolve a 3-way merge with Ediff and write result to OUTPUT.
Called by `jj resolve` merge editor command via emacsclient."
(interactive "fLeft file: \nfBase file: \nfRight file: \nFOutput file: ")
(let* ((left (majutsu-jj-expand-filename-from-jj left))
(base (majutsu-jj-expand-filename-from-jj base))
(right (majutsu-jj-expand-filename-from-jj right))
(output (majutsu-jj-expand-filename-from-jj output))
(marker-length (majutsu-ediff--merge-marker-length marker-length))
(ediff-default-variant 'combined)
(ediff-combination-pattern
(majutsu-ediff--git-combination-pattern marker-length))
(winconf (current-window-configuration)))
(ediff-merge-files-with-ancestor
left right base
(list (lambda ()
(majutsu-ediff--setup-merge-quit-hooks winconf)))
output)
(recursive-edit)))
;;; Transient
(defun majutsu-ediff--default-args ()
"Return default args from diff buffer context."
(when (derived-mode-p 'majutsu-diff-mode)
majutsu-buffer-diff-range))
(defun majutsu-ediff--transient-read-revset (prompt _initial-input _history)
"Read a revset for ediff transient with PROMPT."
(majutsu-read-revset prompt))
;;;###autoload(autoload 'majutsu-ediff "majutsu-ediff" nil t)
(transient-define-prefix majutsu-ediff ()
"Show differences using Ediff."
:incompatible '(("--revisions=" "--from=")
("--revisions=" "--to="))
:transient-non-suffix t
[:description "Ediff"
:class transient-columns
["Selection"
(majutsu-ediff:-r)
(majutsu-ediff:--from)
(majutsu-ediff:--to)
(majutsu-ediff:revisions)
(majutsu-ediff:from)
(majutsu-ediff:to)
("c" "Clear selections" majutsu-selection-clear :transient t)]
["Actions"
("e" "Ediff (blob)" majutsu-ediff-dwim)
("E" "Ediff (diffedit)" majutsu-ediff-edit)]
["Resolve"
("m" "Resolve (ediff)" majutsu-ediff-resolve)
("M" "Resolve (conflict)" majutsu-ediff-resolve-with-conflict)]]
(interactive)
(transient-setup
'majutsu-ediff nil nil
:scope (majutsu-selection-session-begin)
:value (majutsu-ediff--default-args)))
;;;; Infix Commands
(transient-define-argument majutsu-ediff:-r ()
:description "Revisions"
:class 'majutsu-selection-option
:selection-label "[REVS]"
:selection-face '(:background "goldenrod" :foreground "black")
:locate-fn (##majutsu-selection-find-section % 'jj-commit)
:key "-r"
:argument "--revisions="
:multi-value 'repeat
:prompt "Revisions: ")
(transient-define-argument majutsu-ediff:revisions ()
:description "Revisions (toggle at point)"
:class 'majutsu-selection-toggle-option
:key "r"
:argument "--revisions="
:multi-value 'repeat)
(transient-define-argument majutsu-ediff:--from ()
:description "From"
:class 'majutsu-selection-option
:selection-label "[FROM]"
:selection-face '(:background "dark orange" :foreground "black")
:locate-fn (##majutsu-selection-find-section % 'jj-commit)
:key "-f"
:argument "--from="
:reader #'majutsu-ediff--transient-read-revset)
(transient-define-argument majutsu-ediff:--to ()
:description "To"
:class 'majutsu-selection-option
:selection-label "[TO]"
:selection-face '(:background "dark cyan" :foreground "white")
:locate-fn (##majutsu-selection-find-section % 'jj-commit)
:key "-t"
:argument "--to="
:reader #'majutsu-ediff--transient-read-revset)
(transient-define-argument majutsu-ediff:from ()
:description "From (toggle at point)"
:class 'majutsu-selection-toggle-option
:key "f"
:argument "--from=")
(transient-define-argument majutsu-ediff:to ()
:description "To (toggle at point)"
:class 'majutsu-selection-toggle-option
:key "t"
:argument "--to=")
;;; _
(provide 'majutsu-ediff)
;;; majutsu-ediff.el ends here