diff --git a/majutsu-conflict.el b/majutsu-conflict.el new file mode 100644 index 0000000..9a76ce5 --- /dev/null +++ b/majutsu-conflict.el @@ -0,0 +1,893 @@ +;;; majutsu-conflict.el --- Conflict marker parsing for Majutsu -*- lexical-binding: t; -*- + +;; Copyright (C) 2025-2026 0WD0 + +;; Author: 0WD0 +;; Maintainer: 0WD0 +;; Keywords: tools, vc +;; URL: https://github.com/0WD0/majutsu + +;;; Commentary: + +;; This library parses jj conflict markers. It supports all three styles: +;; - "diff" (jj default): shows diff + snapshot +;; - "snapshot": shows all sides as snapshots +;; - "git": Git's diff3 style (2-sided only) +;; +;; The parser auto-detects the style from file content, matching jj's behavior. + +;;; Code: + +(require 'cl-lib) +(require 'smerge-mode) + +;; Silence byte-compiler warnings for dynamically bound variables +(defvar font-lock-beg) +(defvar font-lock-end) +(defvar diff-refine) + +;;; Constants + +(defconst majutsu-conflict-min-marker-len 7 + "Minimum length of conflict markers.") + +;;; Marker Regexps +;; Markers must be at least 7 chars, followed by space or EOL + +(defconst majutsu-conflict-begin-re + "^\\(<\\{7,\\}\\)\\(?: \\(.*\\)\\)?$" + "Regexp matching conflict start marker.") + +(defconst majutsu-conflict-end-re + "^\\(>\\{7,\\}\\)\\(?: \\(.*\\)\\)?$" + "Regexp matching conflict end marker.") + +(defconst majutsu-conflict-add-re + "^\\(\\+\\{7,\\}\\)\\(?: \\(.*\\)\\)?$" + "Regexp matching add (side) marker in JJ-style.") + +(defconst majutsu-conflict-remove-re + "^\\(-\\{7,\\}\\)\\(?: \\(.*\\)\\)?$" + "Regexp matching remove (base) marker in JJ-style.") + +(defconst majutsu-conflict-diff-re + "^\\(%\\{7,\\}\\)\\(?: \\(.*\\)\\)?$" + "Regexp matching diff marker in JJ-style.") + +(defconst majutsu-conflict-note-re + "^\\(\\\\\\{7,\\}\\)\\(?: \\(.*\\)\\)?$" + "Regexp matching note (label continuation) marker.") + +(defconst majutsu-conflict-git-ancestor-re + "^\\(|\\{7,\\}\\)\\(?: \\(.*\\)\\)?$" + "Regexp matching Git-style ancestor (base) marker.") + +(defconst majutsu-conflict-git-separator-re + "^\\(=\\{7,\\}\\)$" + "Regexp matching Git-style separator marker.") + +;;; Data Structures + +(cl-defstruct (majutsu-conflict (:constructor majutsu-conflict--create)) + "Represents a parsed conflict region." + begin-pos ; position of <<<<<<< marker + end-pos ; position after >>>>>>> marker + marker-len ; length of markers (for regeneration) + style ; 'jj-diff, 'jj-snapshot, or 'git + removes ; list of (label . content) for bases + adds ; list of (label . content) for sides + base) ; (label . content) for the snapshot base in jj-diff + +;;; Style Detection + +(defun majutsu-conflict--detect-style (hunk-start) + "Detect conflict style from first line after HUNK-START. +Returns \='jj-diff, \='jj-snapshot, or \='git." + (save-excursion + (goto-char hunk-start) + (forward-line 1) + (cond + ((looking-at majutsu-conflict-diff-re) 'jj-diff) + ((looking-at majutsu-conflict-remove-re) 'jj-snapshot) + ((looking-at majutsu-conflict-add-re) 'jj-snapshot) + ((looking-at majutsu-conflict-git-ancestor-re) 'git) + ;; Default to git (content starts directly) + (t 'git)))) + +;;; JJ-Style Parser + +(defun majutsu-conflict--parse-jj-hunk (begin end _marker-len) + "Parse JJ-style conflict hunk between BEGIN and END. +_MARKER-LEN is the expected marker length (unused, for API consistency). +Returns (STYLE REMOVES ADDS BASE) or nil if invalid. +BASE is the snapshot (rebase destination). +ADDS contains the \"to\" sides, REMOVES contains the \"from\" sides." + (save-excursion + (goto-char begin) + (forward-line 1) ; skip <<<<<<< line + (let ((state 'unknown) + (removes nil) + (adds nil) + (base nil) + (current-remove "") + (current-add "") + (current-remove-label nil) + (current-add-label nil) + (style 'jj-snapshot) + (in-diff nil) ; track if current add is from diff + (seen-remove nil)) ; track if we've seen a remove (for jj-snapshot base detection) + (while (< (point) end) + (let ((line (buffer-substring-no-properties + (line-beginning-position) + (line-end-position)))) + (cond + ;; End marker - stop + ((string-match majutsu-conflict-end-re line) + (goto-char end)) + + ;; Diff marker + ((string-match majutsu-conflict-diff-re line) + ;; Save previous state + (when (eq state 'remove) + (push (cons current-remove-label current-remove) removes)) + (when (eq state 'add) + (if in-diff + (push (cons current-add-label current-add) adds) + (setq base (cons current-add-label current-add)))) + (when (eq state 'diff) + (push (cons current-remove-label current-remove) removes) + (push (cons current-add-label current-add) adds)) + ;; Start new diff pair + (setq state 'diff + style 'jj-diff + in-diff t + current-remove "" + current-add "" + current-remove-label (match-string 2 line) + current-add-label nil)) + + ;; Note marker (label continuation for diff) + ((string-match majutsu-conflict-note-re line) + (when (and (eq state 'diff) (null current-add-label)) + (setq current-add-label (match-string 2 line)))) + + ;; Remove marker + ((string-match majutsu-conflict-remove-re line) + ;; Save previous + (when (eq state 'remove) + (push (cons current-remove-label current-remove) removes)) + (when (eq state 'add) + (if (or in-diff seen-remove) + (push (cons current-add-label current-add) adds) + (setq base (cons current-add-label current-add)))) + (when (eq state 'diff) + (push (cons current-remove-label current-remove) removes) + (push (cons current-add-label current-add) adds)) + ;; Start new remove + (setq state 'remove + in-diff nil + seen-remove t + current-remove "" + current-remove-label (match-string 2 line))) + + ;; Add marker + ((string-match majutsu-conflict-add-re line) + ;; Save previous + (when (eq state 'remove) + (push (cons current-remove-label current-remove) removes)) + (when (eq state 'add) + (if (or in-diff seen-remove) + (push (cons current-add-label current-add) adds) + (setq base (cons current-add-label current-add)))) + (when (eq state 'diff) + (push (cons current-remove-label current-remove) removes) + (push (cons current-add-label current-add) adds)) + ;; Start new add (snapshot, not from diff) + (setq state 'add + in-diff nil + current-add "" + current-add-label (match-string 2 line))) + + ;; Content line + (t + (let ((content (concat line "\n"))) + (pcase state + ('diff + (cond + ((string-prefix-p "-" line) + (setq current-remove + (concat current-remove (substring line 1) "\n"))) + ((string-prefix-p "+" line) + (setq current-add + (concat current-add (substring line 1) "\n"))) + ((string-prefix-p " " line) + (let ((rest (substring line 1))) + (setq current-remove (concat current-remove rest "\n")) + (setq current-add (concat current-add rest "\n")))) + ((string= line "") + (setq current-remove (concat current-remove "\n")) + (setq current-add (concat current-add "\n"))))) + ('remove + (setq current-remove (concat current-remove content))) + ('add + (setq current-add (concat current-add content)))))))) + (forward-line 1)) + + ;; Save final state + (pcase state + ('diff + (push (cons current-remove-label current-remove) removes) + (push (cons current-add-label current-add) adds)) + ('remove + (push (cons current-remove-label current-remove) removes)) + ('add + (if (or in-diff seen-remove) + (push (cons current-add-label current-add) adds) + (setq base (cons current-add-label current-add))))) + + ;; Validate: adds.len == removes.len, and base exists + (if (and base (= (length adds) (length removes))) + (list style (nreverse removes) (nreverse adds) base) + nil)))) + +;;; Git-Style Parser + +(defun majutsu-conflict--parse-git-hunk (begin end _marker-len) + "Parse Git-style conflict hunk between BEGIN and END. +_MARKER-LEN is the expected marker length (unused, for API consistency). +Returns (\='git REMOVES ADDS) or nil if invalid." + (save-excursion + (goto-char begin) + (forward-line 1) ; skip <<<<<<< line + (let ((state 'left) + (left "") + (base "") + (right "") + (left-label nil) + (base-label nil)) + ;; Get left label from <<<<<<< line + (save-excursion + (goto-char begin) + (when (looking-at majutsu-conflict-begin-re) + (setq left-label (match-string 2)))) + + (while (< (point) end) + (let ((line (buffer-substring-no-properties + (line-beginning-position) + (line-end-position)))) + (cond + ;; End marker + ((string-match majutsu-conflict-end-re line) + (goto-char end)) + + ;; Ancestor marker (||||||| base) + ((string-match majutsu-conflict-git-ancestor-re line) + (if (eq state 'left) + (progn + (setq state 'base) + (setq base-label (match-string 2 line))) + ;; Invalid: base must come after left + (setq state 'invalid))) + + ;; Separator (=======) + ((string-match majutsu-conflict-git-separator-re line) + (if (eq state 'base) + (setq state 'right) + ;; Invalid: right must come after base + (setq state 'invalid))) + + ;; Content + (t + (let ((content (concat line "\n"))) + (pcase state + ('left (setq left (concat left content))) + ('base (setq base (concat base content))) + ('right (setq right (concat right content)))))))) + (forward-line 1)) + + ;; Validate: must end in right state + (if (eq state 'right) + (list 'git + (list (cons base-label base)) + (list (cons left-label left) + (cons nil right))) + nil)))) + +;;; Main Parser + +(defun majutsu-conflict-parse-buffer () + "Parse all conflicts in current buffer. +Returns list of `majutsu-conflict' structs." + (save-excursion + (goto-char (point-min)) + (let (conflicts) + (while (re-search-forward majutsu-conflict-begin-re nil t) + (let* ((begin (match-beginning 0)) + (marker-len (length (match-string 1))) + (end-re (format "^>\\{%d,\\}\\(?: .*\\)?$" marker-len))) + ;; Find matching end marker + (when (re-search-forward end-re nil t) + (let* ((end (match-end 0)) + (style (majutsu-conflict--detect-style begin)) + (parsed (if (eq style 'git) + (majutsu-conflict--parse-git-hunk begin end marker-len) + (majutsu-conflict--parse-jj-hunk begin end marker-len)))) + (when parsed + (push (majutsu-conflict--create + :begin-pos begin + :end-pos end + :marker-len marker-len + :style (car parsed) + :removes (cadr parsed) + :adds (caddr parsed) + :base (cadddr parsed)) + conflicts)))))) + (nreverse conflicts)))) + +(defun majutsu-conflict-at-point () + "Return the conflict at point, or nil." + (let ((pos (point)) + (conflicts (majutsu-conflict-parse-buffer))) + (cl-find-if (lambda (c) + (and (<= (majutsu-conflict-begin-pos c) pos) + (<= pos (majutsu-conflict-end-pos c)))) + conflicts))) + +(defun majutsu-conflict-goto-nearest () + "Move point to the nearest conflict marker in the buffer. +Prefer the current conflict when point is inside one." + (interactive) + (let ((conflict (majutsu-conflict-at-point))) + (cond + (conflict + (goto-char (majutsu-conflict-begin-pos conflict))) + ((re-search-forward majutsu-conflict-begin-re nil t) + (goto-char (match-beginning 0))) + ((re-search-backward majutsu-conflict-begin-re nil t) + (goto-char (match-beginning 0))) + (t + (user-error "No conflict markers in buffer"))))) + +;;; Navigation + +(defun majutsu-conflict-next () + "Move to the next conflict marker." + (interactive) + (let ((pos (point))) + (when (looking-at majutsu-conflict-begin-re) + (forward-char)) + (if (re-search-forward majutsu-conflict-begin-re nil t) + (progn + (goto-char (match-beginning 0)) + (when diff-refine + (ignore-errors (majutsu-conflict-refine)))) + (goto-char pos) + (user-error "No more conflicts")))) + +(defun majutsu-conflict-prev () + "Move to the previous conflict marker." + (interactive) + (if (re-search-backward majutsu-conflict-begin-re nil t) + (when diff-refine + (ignore-errors (majutsu-conflict-refine))) + (user-error "No previous conflict"))) + +;;; Resolution Helpers + +(defun majutsu-conflict-resolve-with (conflict content) + "Replace CONFLICT region with CONTENT." + (save-excursion + (delete-region (majutsu-conflict-begin-pos conflict) + (majutsu-conflict-end-pos conflict)) + (goto-char (majutsu-conflict-begin-pos conflict)) + (insert content))) + +(defun majutsu-conflict-keep-side (n &optional before) + "Keep side N (1-indexed) of the conflict at point. +With prefix arg (BEFORE non-nil), keep the \"from\" state (before diff). +Without prefix, keep the \"to\" state (after diff). + +In jj diff-style conflicts: +- adds[0] = base (rebase destination) +- adds[N] = diff N's \"to\" state +- removes[N-1] = diff N's \"from\" state" + (interactive "p\nP") + (let ((conflict (majutsu-conflict-at-point))) + (unless conflict + (user-error "No conflict at point")) + (let* ((adds (majutsu-conflict-adds conflict)) + (removes (majutsu-conflict-removes conflict)) + (content (if before + (cdr (nth (1- n) removes)) + (cdr (nth n adds))))) + (unless content + (user-error "No side %d%s" n (if before " (before)" ""))) + (majutsu-conflict-resolve-with conflict content)))) + +(defun majutsu-conflict-keep-base () + "Keep the base (rebase destination) of the conflict at point. +In jj diff-style conflicts, this is the snapshot (not from diff blocks)." + (interactive) + (let ((conflict (majutsu-conflict-at-point))) + (unless conflict + (user-error "No conflict at point")) + (let ((base (or (majutsu-conflict-base conflict) + ;; For jj-snapshot, base is the first add + (car (majutsu-conflict-adds conflict))))) + (unless base + (user-error "No base in this conflict")) + (majutsu-conflict-resolve-with conflict (cdr base))))) + +;;; Minor Mode + +(defvar-local majutsu-conflict--overlays nil + "List of overlays for conflict highlighting (refine only).") + +(defvar-local majutsu-conflict--in-diff nil + "Non-nil when inside a diff section during font-lock.") + +(defvar-local majutsu-conflict--in-add nil + "Non-nil when inside a ++++++ section.") + +(defvar-local majutsu-conflict--in-remove nil + "Non-nil when inside a ------ section.") + +(defvar-local majutsu-conflict--style nil + "Current conflict style: `jj-diff' or `jj-snapshot'.") + +(defvar-local majutsu-conflict--is-first-add nil + "Non-nil when current ++++++ section is the first one (base).") + +(defvar-local majutsu-conflict--add-count 0 + "Count of ++++++ sections seen in current conflict.") + +(defvar majutsu-conflict-mode-map + (let ((map (make-sparse-keymap))) + (define-key map (kbd "C-c ^ n") #'majutsu-conflict-next) + (define-key map (kbd "C-c ^ p") #'majutsu-conflict-prev) + (define-key map (kbd "C-c ^ b") #'majutsu-conflict-keep-base) + (define-key map (kbd "C-c ^ R") #'majutsu-conflict-refine) + ;; 1-9 for sides (after), M-1 to M-9 for before + (dotimes (i 9) + (let ((n (1+ i))) + (define-key map (kbd (format "C-c ^ %d" n)) + (lambda () (interactive) + (majutsu-conflict-keep-side n nil))) + (define-key map (kbd (format "C-c ^ M-%d" n)) + (lambda () (interactive) + (majutsu-conflict-keep-side n t))))) + map) + "Keymap for `majutsu-conflict-mode'. +\\ +\\[majutsu-conflict-next] - next conflict +\\[majutsu-conflict-prev] - previous conflict +\\[majutsu-conflict-keep-base] - keep base (rebase destination) +\\[majutsu-conflict-refine] - add word-level refinement +C-c ^ 1-9 - keep side N (after diff) +C-c ^ M-1 to M-9 - keep side N (before diff)") + +(defun majutsu-conflict--git-style-only-p () + "Return non-nil if buffer only contains Git-style conflicts." + (let ((conflicts (majutsu-conflict-parse-buffer))) + (and conflicts + (cl-every (lambda (c) (eq (majutsu-conflict-style c) 'git)) + conflicts)))) + +(defun majutsu-conflict--clear-overlays () + "Remove all conflict overlays." + (mapc #'delete-overlay majutsu-conflict--overlays) + (setq majutsu-conflict--overlays nil) + ;; Remove refine overlays created by smerge-refine-regions + (remove-overlays (point-min) (point-max) 'majutsu-conflict-refine t)) + +(defface majutsu-conflict-marker-face + '((((background light)) + (:background "grey85" :extend t)) + (((background dark)) + (:background "grey30" :extend t))) + "Face for conflict marker lines." + :group 'majutsu) + +(defface majutsu-conflict-base-face + '((default :inherit smerge-base :extend t)) + "Face for base content in conflicts." + :group 'majutsu) + +(defface majutsu-conflict-context-face + '((((background light)) + (:background "grey95" :extend t)) + (((background dark)) + (:background "grey25" :extend t))) + "Face for context lines in conflict diffs." + :group 'majutsu) + +(defface majutsu-conflict-added-face + '((((class color) (min-colors 88) (background light)) + :background "#ddffdd" :extend t) + (((class color) (min-colors 88) (background dark)) + :background "#335533" :extend t) + (((class color)) + :foreground "green" :extend t)) + "Face for added lines in conflict diffs." + :group 'majutsu) + +(defface majutsu-conflict-removed-face + '((((class color) (min-colors 88) (background light)) + :background "#ffdddd" :extend t) + (((class color) (min-colors 88) (background dark)) + :background "#553333" :extend t) + (((class color)) + :foreground "red" :extend t)) + "Face for removed lines in conflict diffs." + :group 'majutsu) + +(defface majutsu-conflict-refined-added + '((default :inherit diff-refine-added) + (((class color) (min-colors 88) (background light)) + :background "#aaffaa") + (((class color) (min-colors 88) (background dark)) + :background "#22aa22")) + "Face for refined added regions in conflict diffs." + :group 'majutsu) + +(defface majutsu-conflict-refined-removed + '((default :inherit diff-refine-removed) + (((class color) (min-colors 88) (background light)) + :background "#ffbbbb") + (((class color) (min-colors 88) (background dark)) + :background "#aa2222")) + "Face for refined removed regions in conflict diffs." + :group 'majutsu) + +;;; Font-Lock Support + +(defun majutsu-conflict--find-conflict (&optional limit) + "Find and match a JJ-style conflict region. Intended as a font-lock MATCHER. +Skips git-style conflicts (left to `smerge-mode'). +Returns non-nil if a match is found between point and LIMIT. +Sets match-data with group 0 = entire conflict." + (let ((found nil)) + (while (and (not found) (re-search-forward majutsu-conflict-begin-re limit t)) + (let* ((begin (match-beginning 0)) + (marker-len (length (match-string 1))) + (end-re (format "^>\\{%d,\\}\\(?: .*\\)?$" marker-len)) + (style (majutsu-conflict--detect-style begin))) + (when (and (memq style '(jj-diff jj-snapshot)) + (re-search-forward end-re limit t)) + (set-match-data (list begin (match-end 0))) + (with-silent-modifications + (put-text-property begin (match-end 0) 'font-lock-multiline t)) + (setq found t)))) + found)) + +(defun majutsu-conflict--match-line (limit) + "Match any line within JJ conflict. Font-lock ANCHORED-MATCHER. +Sets match-data and updates state variables." + (when (< (point) limit) + (let ((line-beg (line-beginning-position)) + (line-end (line-end-position))) + (cond + ;; Marker line + ((looking-at "^\\([<>+%\\-]\\{7,\\}\\)\\(?: .*\\)?$") + (let ((char (char-after (match-beginning 1)))) + (cond + ((= char ?<) + ;; Detect style from next line + (setq majutsu-conflict--in-diff nil + majutsu-conflict--in-add nil + majutsu-conflict--in-remove nil + majutsu-conflict--add-count 0) + (save-excursion + (forward-line 1) + (setq majutsu-conflict--style + (cond + ((looking-at majutsu-conflict-diff-re) 'jj-diff) + ((or (looking-at majutsu-conflict-add-re) + (looking-at majutsu-conflict-remove-re)) + 'jj-snapshot) + (t nil))))) + ((= char ?>) + (setq majutsu-conflict--in-diff nil + majutsu-conflict--in-add nil + majutsu-conflict--in-remove nil)) + ((= char ?%) + (setq majutsu-conflict--in-diff t + majutsu-conflict--in-add nil + majutsu-conflict--in-remove nil)) + ((= char ?+) + (setq majutsu-conflict--in-diff nil + majutsu-conflict--in-add t + majutsu-conflict--in-remove nil) + (cl-incf majutsu-conflict--add-count)) + ((= char ?-) + (setq majutsu-conflict--in-diff nil + majutsu-conflict--in-add nil + majutsu-conflict--in-remove t)))) + (set-match-data (list line-beg (min (1+ line-end) (point-max)))) + (goto-char (min (1+ line-end) (point-max))) + t) + ;; Note marker + ((looking-at majutsu-conflict-note-re) + (set-match-data (list line-beg (min (1+ line-end) (point-max)))) + (goto-char (min (1+ line-end) (point-max))) + t) + ;; Content line + (t + (set-match-data (list line-beg (min (1+ line-end) (point-max)))) + (goto-char (min (1+ line-end) (point-max))) + t))))) + +(defun majutsu-conflict--line-face () + "Return face for current line based on state and content." + (save-excursion + (goto-char (match-beginning 0)) + (cond + ;; Marker lines + ((looking-at "^[<>+%\\-]\\{7,\\}") + 'majutsu-conflict-marker-face) + ((looking-at majutsu-conflict-note-re) + 'majutsu-conflict-marker-face) + ;; JJ diff style content + (majutsu-conflict--in-diff + (cond + ((looking-at "^\\+") 'majutsu-conflict-added-face) + ((looking-at "^-") 'majutsu-conflict-removed-face) + ((looking-at "^ ") 'majutsu-conflict-context-face) + (t nil))) + ;; In ++++++ section + (majutsu-conflict--in-add + (cond + ;; In jj-diff, ++++++ is always the base + ((eq majutsu-conflict--style 'jj-diff) + 'majutsu-conflict-base-face) + ;; In jj-snapshot, first ++++++ (count=1) is base + ((= majutsu-conflict--add-count 1) + 'majutsu-conflict-base-face) + (t + ;; Subsequent ++++++ sections are "to" sides + 'majutsu-conflict-added-face))) + ;; In ------ section ("from" side in snapshot style) + (majutsu-conflict--in-remove + 'majutsu-conflict-removed-face) + (t nil)))) + +(defconst majutsu-conflict-font-lock-keywords + `((majutsu-conflict--find-conflict + (majutsu-conflict--match-line + ;; PRE-FORM: reset state and return conflict end as search limit + (progn + (setq majutsu-conflict--in-diff nil + majutsu-conflict--in-add nil + majutsu-conflict--in-remove nil + majutsu-conflict--add-count 0) + (goto-char (match-beginning 0)) + (match-end 0)) ; Return conflict end position as limit + nil + (0 (majutsu-conflict--line-face) prepend t)))) + "Font lock keywords for `majutsu-conflict-mode'.") + +(defun majutsu-conflict--add-overlay (beg end face) + "Add refine overlay from BEG to END with FACE." + (let ((ov (make-overlay beg end))) + (overlay-put ov 'face face) + (overlay-put ov 'majutsu-conflict t) + (overlay-put ov 'evaporate t) + (overlay-put ov 'priority 100) + (push ov majutsu-conflict--overlays))) + +(defun majutsu-conflict--clear-refine-overlays (beg end) + "Remove refinement overlays between BEG and END." + (remove-overlays beg end 'majutsu-conflict-refine t)) + +(defun majutsu-conflict--refine-pair (remove-beg remove-end add-beg add-end) + "Refine a removed/added region pair. +REMOVE-BEG/REMOVE-END and ADD-BEG/ADD-END are content-only ranges (no +/-)." + (when (and remove-beg remove-end add-beg add-end + (< remove-beg remove-end) (< add-beg add-end)) + (let ((props-r `((majutsu-conflict-refine . t) + (face . majutsu-conflict-refined-removed))) + (props-a `((majutsu-conflict-refine . t) + (face . majutsu-conflict-refined-added))) + (smerge-refine-ignore-whitespace t) + (write-region-inhibit-fsync t)) + (smerge-refine-regions remove-beg remove-end add-beg add-end + nil nil props-r props-a) + (dolist (ov (overlays-in remove-beg add-end)) + (when (overlay-get ov 'majutsu-conflict-refine) + (overlay-put ov 'priority 100)))))) + +(defun majutsu-conflict--refine-diff-region (beg end) + "Apply word-level refinement to diff lines between BEG and END." + (save-excursion + (majutsu-conflict--clear-refine-overlays beg end) + (goto-char beg) + (let ((remove-beg nil) + (remove-end nil) + (add-beg nil) + (add-end nil)) + (cl-labels + ((flush () + (majutsu-conflict--refine-pair + remove-beg remove-end add-beg add-end) + (setq remove-beg nil remove-end nil + add-beg nil add-end nil))) + (while (< (point) end) + (cond + ((looking-at "^-") + (when add-beg (flush)) + (let ((beg (1+ (line-beginning-position))) + (end (min end (1+ (line-end-position))))) + (if remove-beg + (setq remove-end end) + (setq remove-beg beg + remove-end end)))) + ((looking-at "^+") + (let ((beg (1+ (line-beginning-position))) + (end (min end (1+ (line-end-position))))) + (if add-beg + (setq add-end end) + (setq add-beg beg + add-end end)))) + (t + (flush))) + (forward-line 1)) + (flush))))) + +(defun majutsu-conflict--refine-diffs () + "Add word-level refinement to JJ diff sections." + (save-excursion + (goto-char (point-min)) + (while (re-search-forward majutsu-conflict-diff-re nil t) + ;; Skip to content (past note marker if present) + (forward-line 1) + (when (looking-at majutsu-conflict-note-re) + (forward-line 1)) + (let ((content-start (point))) + (while (and (not (eobp)) + (not (looking-at "^[<>+%\\|-]\\{7,\\}"))) + (forward-line 1)) + (let ((content-end (point))) + (when (> content-end content-start) + (majutsu-conflict--refine-diff-region content-start content-end))))))) + +(defun majutsu-conflict--refine-snapshots () + "Add word-level refinement to JJ snapshot-style conflicts. +Compares each ------- section with the following +++++++ section." + (save-excursion + (goto-char (point-min)) + (while (re-search-forward majutsu-conflict-remove-re nil t) + (forward-line 1) + (let ((remove-start (point))) + ;; Find end of remove section + (while (and (not (eobp)) + (not (looking-at "^[<>+%\\-]\\{7,\\}"))) + (forward-line 1)) + (let ((remove-end (point))) + ;; Check if next marker is +++++++ + (when (looking-at majutsu-conflict-add-re) + (forward-line 1) + (let ((add-start (point))) + ;; Find end of add section + (while (and (not (eobp)) + (not (looking-at "^[<>+%\\-]\\{7,\\}"))) + (forward-line 1)) + (let ((add-end (point))) + (when (and (< remove-start remove-end) + (< add-start add-end)) + (majutsu-conflict--refine-pair + remove-start remove-end add-start add-end)))))))))) + +(defun majutsu-conflict--fontify-region (beg end) + "Force font-lock to fontify BEG to END." + (with-demoted-errors "%S" + (font-lock-fontify-region beg end nil))) + +(defun majutsu-conflict--fontify-conflicts () + "Fontify all conflict regions in the current buffer." + (save-excursion + (goto-char (point-min)) + (while (majutsu-conflict--find-conflict) + (majutsu-conflict--fontify-region (match-beginning 0) (match-end 0))))) + +(defun majutsu-conflict--extend-font-lock-region () + "Extend font-lock region to cover the whole conflict. +Return non-nil when the region was expanded." + (let ((orig-beg font-lock-beg) + (orig-end font-lock-end) + (expanded nil)) + (save-excursion + (goto-char orig-beg) + (when (re-search-backward majutsu-conflict-begin-re nil t) + (let* ((begin (match-beginning 0)) + (marker-len (length (match-string 1))) + (end-re (format "^>\\{%d,\\}\\(?: .*\\)?$" marker-len))) + (when (re-search-forward end-re nil t) + (let ((end (match-end 0))) + (when (or (and (<= begin orig-beg) (< orig-beg end)) + (and (<= begin orig-end) (< orig-end end))) + (when (or (< begin orig-beg) (> end orig-end)) + (setq font-lock-beg begin + font-lock-end end + expanded t)))))))) + expanded)) + +;;;###autoload +(defun majutsu-conflict-refine () + "Add word-level refinement to conflict regions. +Call this command to highlight fine-grained differences within conflicts." + (interactive) + (majutsu-conflict--clear-refine-overlays (point-min) (point-max)) + (majutsu-conflict--refine-diffs) + (majutsu-conflict--refine-snapshots)) + +(defun majutsu-conflict--after-change (beg end _len) + "Refontify conflicts after edits in BEG..END." + (when (and majutsu-conflict-mode font-lock-mode) + (font-lock-flush beg end) + (font-lock-ensure beg end))) + +(defun majutsu-conflict--enable () + "Enable conflict highlighting for JJ-style conflicts." + ;; Add font-lock keywords for line-level highlighting + (font-lock-add-keywords nil majutsu-conflict-font-lock-keywords 'append) + (add-hook 'font-lock-extend-region-functions + #'majutsu-conflict--extend-font-lock-region nil t) + (add-hook 'after-change-functions #'majutsu-conflict--after-change nil t) + (when font-lock-mode + (font-lock-flush) + (majutsu-conflict--fontify-conflicts))) + +(defun majutsu-conflict--disable () + "Disable conflict highlighting." + (font-lock-remove-keywords nil majutsu-conflict-font-lock-keywords) + (remove-hook 'font-lock-extend-region-functions + #'majutsu-conflict--extend-font-lock-region t) + (remove-hook 'after-change-functions #'majutsu-conflict--after-change t) + (when font-lock-mode + (font-lock-flush)) + (majutsu-conflict--clear-overlays)) + +;;;###autoload +(define-minor-mode majutsu-conflict-mode + "Minor mode for jj conflict markers. +Provides highlighting and navigation for conflict regions." + :lighter " Conflict" + :keymap majutsu-conflict-mode-map + (if majutsu-conflict-mode + (majutsu-conflict--enable) + (majutsu-conflict--disable))) + +(defun majutsu-conflict--scan-styles () + "Return a list of conflict styles found in the current buffer." + (save-excursion + (goto-char (point-min)) + (let (styles) + (while (re-search-forward majutsu-conflict-begin-re nil t) + (push (majutsu-conflict--detect-style (match-beginning 0)) styles)) + styles))) + +;;;###autoload +(defun majutsu-conflict-ensure-mode () + "Enable conflict mode based on marker style in the buffer. + +Prefer `majutsu-conflict-mode' for JJ-style conflicts. If only Git-style +markers are present, enable `smerge-mode'." + (let* ((styles (majutsu-conflict--scan-styles)) + (jj-style (cl-some (lambda (style) + (memq style '(jj-diff jj-snapshot))) + styles)) + (git-style (cl-some (lambda (style) + (eq style 'git)) + styles))) + (cond + (jj-style (majutsu-conflict-mode 1)) + (git-style (smerge-mode 1))))) + +;;;###autoload +(defun majutsu-conflict-check-enable () + "Enable conflict mode if buffer has conflict markers." + (majutsu-conflict-ensure-mode)) + +;;; _ +(provide 'majutsu-conflict) +;;; majutsu-conflict.el ends here diff --git a/majutsu-diff.el b/majutsu-diff.el index 16af87d..bf9ee6d 100644 --- a/majutsu-diff.el +++ b/majutsu-diff.el @@ -22,6 +22,7 @@ (require 'majutsu-selection) (require 'majutsu-section) (require 'majutsu-file) +(require 'majutsu-conflict) (require 'magit-diff) ; for faces/font-lock keywords (require 'diff-mode) (require 'smerge-mode) @@ -190,10 +191,12 @@ This intentionally keeps only jj diff \"Diff Formatting Options\"." (defclass majutsu-diff--toggle-range-option (majutsu-selection-toggle-option) ()) (cl-defmethod transient-init-value ((obj majutsu-diff-prefix)) - (pcase-let ((`(,args ,range ,_filesets) + (pcase-let ((`(,args ,range ,filesets) (majutsu-diff--get-value (oref obj major-mode) 'prefix))) (oset obj value - (append range args)))) + (if filesets + `(("--" ,@filesets) ,@range ,@args) + (append range args))))) (cl-defmethod transient-prefix-value ((obj majutsu-diff-prefix)) "Return (ARGS RANGE FILESETS) for the Majutsu diff transient. @@ -204,14 +207,7 @@ list of filesets (path filters)." (let* ((raw (cl-call-next-method obj)) (args (majutsu-diff--remembered-args raw)) (range (majutsu-diff--extract-range-args raw)) - (mode (or (oref obj major-mode) major-mode)) - (filesets - (cond - ((buffer-live-p (majutsu-diff--transient-original-buffer)) - (buffer-local-value 'majutsu-buffer-diff-filesets - (majutsu-diff--transient-original-buffer))) - (t - (nth 2 (majutsu-diff--get-value mode 'direct)))))) + (filesets (cdr (assoc "--" raw)))) (list args range filesets))) (cl-defmethod transient-set-value ((obj majutsu-diff-prefix)) @@ -608,7 +604,7 @@ When SECTION is nil, walk all hunk sections." (oref section end) 'diff-mode 'fine)))) (cl-labels ((walk (node) - (if (magit-section-match 'majutsu-hunk-section node) + (if (magit-section-match 'jj-hunk node) (majutsu-diff--update-hunk-refinement node t) (dolist (child (oref node children)) (walk child))))) @@ -799,8 +795,8 @@ regardless of what the diff is about." (let* ((goto-from (and section (magit-section-match 'jj-hunk section) (majutsu-diff--on-removed-line-p))) (goto-workspace (or force-workspace - (and (majutsu-diff--visit-workspace-p) - (not goto-from)))) + (and (majutsu-diff--visit-workspace-p) + (not goto-from)))) (line (and section (magit-section-match 'jj-hunk section) (majutsu-diff--hunk-line section goto-from))) (col (and section (magit-section-match 'jj-hunk section) @@ -838,160 +834,49 @@ what the diff is about." (interactive) (majutsu-diff-visit-file t)) +;;;###autoload +(defun majutsu-diff-resolve-conflict () + "Visit the workspace file and jump to its conflict markers. + +Enable `majutsu-conflict-mode' for JJ markers or `smerge-mode' for +Git-style markers." + (interactive) + (let ((file (majutsu-file-at-point))) + (unless file + (user-error "No file at point")) + (majutsu-diff-visit-file t) + (majutsu-conflict-ensure-mode) + (cond + (majutsu-conflict-mode + (majutsu-conflict-goto-nearest) + (when diff-refine + (ignore-errors (majutsu-conflict-refine)))) + (smerge-mode + (condition-case nil + (smerge-match-conflict) + (error + (smerge-next))))) + (message "Use C-c ^ commands to resolve conflicts."))) + (defvar-keymap majutsu-file-section-map :doc "Keymap for `jj-file' sections." :parent majutsu-diff-section-map "v" #'majutsu-find-file-at-point) +(defvar-keymap majutsu-hunk-section-conflict-map + :doc "Keymap bound to `smerge-command-prefix' in `majutsu-hunk-section-map'." + "RET" #'majutsu-diff-resolve-conflict) + (defvar-keymap majutsu-hunk-section-map :doc "Keymap for `jj-hunk' sections." :parent majutsu-diff-section-map) -;;; Diff Edit - -;;;###autoload -(defun majutsu-diffedit-emacs () - "Emacs-based diffedit using built-in ediff." - (interactive) - (let* ((file (majutsu-file-at-point))) - (if file - (majutsu-diffedit-with-ediff file) - (majutsu-diffedit-all)))) - -(defun majutsu-diffedit-with-ediff (file) - "Open ediff session for a specific file against parent." - (let* ((repo-root default-directory) - (full-file-path (expand-file-name file repo-root)) - (file-ext (file-name-extension file)) - (parent-temp-file (make-temp-file (format "majutsu-parent-%s" (file-name-nondirectory file)) - nil (when file-ext (concat "." file-ext)))) - (parent-content (let ((default-directory repo-root)) - (majutsu-jj-string "file" "show" "-r" "@-" file)))) - - ;; Write parent content to temp file - (with-temp-file parent-temp-file - (insert parent-content) - ;; Enable proper major mode for syntax highlighting - (when file-ext - (let ((mode (assoc-default (concat "." file-ext) auto-mode-alist 'string-match))) - (when mode - (funcall mode))))) - - ;; Set up cleanup - (add-hook 'ediff-quit-hook - (lambda () - (when (file-exists-p parent-temp-file) - (delete-file parent-temp-file)) - (majutsu-refresh)) - nil t) - - ;; Start ediff session - (ediff-files parent-temp-file full-file-path) - (message "Ediff: Left=Parent (@-), Right=Current (@). Edit right side, then 'q' to quit and save."))) - -(defvar-local majutsu-smerge-file nil - "File being merged in smerge session.") - -(defvar-local majutsu-smerge-repo-root nil - "Repository root for smerge session.") - -;;;###autoload -(defun majutsu-diffedit-smerge () - "Emacs-based diffedit using smerge-mode (merge conflict style)." - (interactive) - (let* ((file (majutsu-file-at-point))) - (if file - (majutsu-diffedit-with-smerge file) - (majutsu-diffedit-all)))) - -(defun majutsu-diffedit-with-smerge (file) - "Open smerge-mode session for a specific file." - (let* ((repo-root default-directory) - (full-file-path (expand-file-name file repo-root)) - (parent-content (let ((default-directory repo-root)) - (majutsu-jj-string "file" "show" "-r" "@-" file))) - (current-content (if (file-exists-p full-file-path) - (with-temp-buffer - (insert-file-contents full-file-path) - (buffer-string)) - "")) - (merge-buffer (get-buffer-create (format "*majutsu-smerge-%s*" (file-name-nondirectory file))))) - - (with-current-buffer merge-buffer - (erase-buffer) - - ;; Create merge-conflict format - (insert "<<<<<<< Parent (@-)\n") - (insert parent-content) - (unless (string-suffix-p "\n" parent-content) - (insert "\n")) - (insert "=======\n") - (insert current-content) - (unless (string-suffix-p "\n" current-content) - (insert "\n")) - (insert ">>>>>>> Current (@)\n") - - ;; Enable smerge-mode - (smerge-mode 1) - (setq-local majutsu-smerge-file file) - (setq-local majutsu-smerge-repo-root repo-root) - - ;; Add save hook - (add-hook 'after-save-hook 'majutsu-smerge-apply-changes nil t) - - (goto-char (point-min))) - - (switch-to-buffer-other-window merge-buffer) - (message "SMerge mode: Use C-c ^ commands to navigate/resolve conflicts, then save to apply."))) - -(defun majutsu-smerge-apply-changes () - "Apply smerge changes to the original file." - (when (and (boundp 'majutsu-smerge-file) majutsu-smerge-file) - (let* ((file majutsu-smerge-file) - (repo-root majutsu-smerge-repo-root) - (full-file-path (expand-file-name file repo-root)) - (content (buffer-string))) - - ;; Only apply if no conflict markers remain - (unless (or (string-match "^<<<<<<<" content) - (string-match "^=======" content) - (string-match "^>>>>>>>" content)) - (with-temp-file full-file-path - (insert content)) - (majutsu-refresh) - (message "Changes applied to %s" file))))) - -(defun majutsu-diffedit-all () - "Open diffedit interface for all changes." - (let* ((changed-files (majutsu--get-changed-files)) - (choice (if (= (length changed-files) 1) - (car changed-files) - (majutsu-completing-read "Edit file" changed-files)))) - (when choice - (majutsu-diffedit-with-ediff choice)))) - -(defun majutsu--get-changed-files () - "Get list of files with changes in working copy." - (let ((diff-output (majutsu-jj-string "diff" "--name-only"))) - (split-string diff-output "\n" t))) +(let ((key (key-description smerge-command-prefix))) + (when (key-valid-p key) + (keymap-set majutsu-hunk-section-map key majutsu-hunk-section-conflict-map))) ;;; Diff Commands -(defun majutsu-diff-clear-selections () - "Clear all diff selections." - (interactive) - (majutsu-selection-clear 'from) - (majutsu-selection-clear 'to) - (when (consp transient--suffixes) - (dolist (obj transient--suffixes) - (when (and (cl-typep obj 'majutsu-diff--range-option) - (memq (oref obj selection-key) '(from to))) - (transient-infix-set obj nil)))) - (when transient--prefix - (transient--redisplay)) - (when (called-interactively-p 'interactive) - (message "Cleared diff selections"))) - (defun majutsu-diff-less-context (&optional count) "Decrease the context for diff hunks by COUNT lines." (interactive "p") @@ -1042,7 +927,7 @@ With prefix STYLE, cycle between `all' and `t'." "0" #'majutsu-diff-default-context "j" #'majutsu-jump-to-diffstat-or-diff) -(define-derived-mode majutsu-diff-mode majutsu-mode "JJ Diff" +(define-derived-mode majutsu-diff-mode majutsu-mode "Majutsu Diff" "Major mode for viewing jj diffs." :group 'majutsu (setq-local line-number-mode nil) @@ -1128,7 +1013,9 @@ REVSET is passed to jj diff using `--revisions='." (majutsu-diff:--to) (majutsu-diff:from) (majutsu-diff:to) - ("c" "Clear selections" majutsu-diff-clear-selections :transient t)] + ("c" "Clear selections" majutsu-selection-clear :transient t)] + ["Paths" + (majutsu-diff:--)] ["Options" (majutsu-diff:--git) (majutsu-diff:--stat) @@ -1173,6 +1060,15 @@ REVSET is passed to jj diff using `--revisions='." :argument "--context=" :reader #'transient-read-number-N0) +(transient-define-argument majutsu-diff:-- () + :description "Limit to files" + :class 'transient-files + :key "--" + :argument "--" + :prompt "Limit to file,s: " + :reader #'majutsu-read-files + :multi-value t) + (transient-define-argument majutsu-diff:-r () :description "Revisions" :class 'transient-option diff --git a/majutsu-duplicate.el b/majutsu-duplicate.el index aa6f856..582aa72 100644 --- a/majutsu-duplicate.el +++ b/majutsu-duplicate.el @@ -134,19 +134,6 @@ With prefix ARG, open the duplicate transient." :argument "--insert-before=" :multi-value 'repeat) -(defun majutsu-duplicate-clear-selections () - "Clear duplicate selections." - (interactive) - (when (consp transient--suffixes) - (dolist (obj transient--suffixes) - (when (and (cl-typep obj 'majutsu-duplicate-option) - (memq (oref obj selection-key) '(source onto after before))) - (transient-infix-set obj nil)))) - (when transient--prefix - (transient--redisplay)) - (majutsu-selection-render) - (message "Cleared duplicate selections")) - (transient-define-prefix majutsu-duplicate () "Internal transient for jj duplicate." :man-page "jj-duplicate" @@ -156,7 +143,7 @@ With prefix ARG, open the duplicate transient." ["Sources" (majutsu-duplicate:-r) (majutsu-duplicate:source) - ("c" "Clear selections" majutsu-duplicate-clear-selections + ("c" "Clear selections" majutsu-selection-clear :transient t)] ["Placement" (majutsu-duplicate:--onto) diff --git a/majutsu-ediff.el b/majutsu-ediff.el new file mode 100644 index 0000000..8e504c6 --- /dev/null +++ b/majutsu-ediff.el @@ -0,0 +1,450 @@ +;;; majutsu-ediff.el --- Ediff extension for Majutsu -*- lexical-binding: t; -*- + +;; Copyright (C) 2026 0WD0 + +;; Author: 0WD0 +;; Maintainer: 0WD0 +;; Keywords: tools, vc +;; URL: https://github.com/0WD0/majutsu + +;;; 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-process) +(require 'majutsu-file) +(require 'majutsu-conflict) + +(declare-function majutsu-diff-visit-file "majutsu-diff" (&optional force-workspace)) + +(defvar majutsu-buffer-diff-range) + +;;; 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 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." + (let ((root (majutsu-file--root))) + (majutsu-find-file--ensure-buffer root rev file))) + +(defun majutsu-ediff--parse-diff-range (range) + "Parse RANGE into (from . to) cons. +RANGE is a list like (\"--revisions=xxx\") or (\"--from=xxx\" \"--to=xxx\")." + (when range + (let ((from nil) (to nil) (revisions nil)) + (dolist (arg range) + (cond + ((string-prefix-p "--from=" arg) + (setq from (substring arg 7))) + ((string-prefix-p "--to=" arg) + (setq to (substring arg 5))) + ((string-prefix-p "--revisions=" arg) + (setq revisions (substring arg 12))) + ((string-prefix-p "-r" arg) + (setq revisions (substring arg 2))))) + (cond + (revisions (cons (concat revisions "-") revisions)) + ((and from to) (cons from to)) + (from (cons from "@")) + (to (cons "@-" to)) + (t (cons "@-" "@")))))) + +(defun majutsu-ediff--read-file (from to) + "Read file to compare between FROM and TO." + (let* ((root (majutsu-file--root)) + (default-directory root) + (changed (split-string + (majutsu-jj-string "diff" "--from" from "--to" to "--name-only") + "\n" t))) + (if (= (length changed) 1) + (car changed) + (completing-read + (format "File to compare between %s and %s: " from to) + changed nil t)))) + +(defun majutsu-ediff--list-conflicted-files (&optional rev) + "Return list of conflicted files at REV (default @)." + (let* ((default-directory (majutsu-file--root)) + (output (majutsu-jj-string "resolve" "--list" "-r" (or rev "@"))) + (lines (seq-remove #'string-empty-p (split-string output "\n")))) + ;; Parse "filename N-sided conflict" format + (mapcar (lambda (line) + (if (string-match "^\\([^ \t]+\\)" line) + (match-string 1 line) + 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))))) + +;;; 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-ediff--read-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-ediff--read-file parent rev)))) + (majutsu-ediff-compare parent rev file))) + +(defun majutsu-ediff--current-range () + "Return the current diff range from transient or buffer." + (majutsu-ediff--parse-diff-range + (if (eq transient-current-command 'majutsu-ediff) + (transient-args 'majutsu-ediff) + majutsu-buffer-diff-range))) + +;;;###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 (majutsu--normalize-id-value (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-resolve (&optional file) + "Resolve conflicts in FILE using Ediff. +For conflicts with more than 2 sides, fall back to `majutsu-conflict'." + (interactive) + (let* ((file (or file + (majutsu-file-at-point) + (majutsu-ediff--read-conflicted-file))) + (full-path (expand-file-name file (majutsu-file--root)))) + (unless (file-exists-p full-path) + (user-error "File does not exist: %s" file)) + (find-file full-path) + (let ((conflicts (majutsu-conflict-parse-buffer))) + (cond + ((null conflicts) + (user-error "No conflicts in %s" file)) + ;; Check if any conflict has more than 2 sides + ((cl-some (lambda (c) + (> (+ (length (majutsu-conflict-adds c)) + (length (majutsu-conflict-removes c))) + 3)) ; base + 2 sides = 3 + conflicts) + (message "Conflict has more than 2 sides, using majutsu-conflict") + (majutsu-conflict-ensure-mode) + (majutsu-conflict-goto-nearest)) + ;; Git-style conflicts: use smerge-ediff + ((majutsu-conflict--git-style-only-p) + (smerge-ediff)) + ;; JJ-style with 2 sides: use majutsu-conflict for now + ;; (ediff 3-way merge requires extracting base/left/right content) + (t + (majutsu-conflict-ensure-mode) + (majutsu-conflict-goto-nearest) + (message "Use C-c ^ commands to resolve conflicts")))))) + +;;;###autoload +(defun majutsu-ediff-resolve-with-conflict () + "Resolve conflicts using `majutsu-conflict-mode'." + (interactive) + (let* ((file (or (majutsu-file-at-point) + (majutsu-ediff--read-conflicted-file))) + (full-path (expand-file-name file (majutsu-file--root)))) + (find-file full-path) + (majutsu-conflict-ensure-mode) + (majutsu-conflict-goto-nearest))) + +;;;###autoload +(defun majutsu-ediff-edit (args) + "Edit the right side of a diff using jj diffedit with Emacs as diff-editor. +ARGS are transient arguments." + (interactive (list (transient-args 'majutsu-ediff))) + (let* ((range (majutsu-ediff--parse-diff-range args)) + (from (car range)) + (to (cdr range)) + (jj-args (cond + ((and from to) + (list "--from" from "--to" to)) + (to + (list "-r" to)) + (from + (list "-r" from)) + (t + (list "-r" "@"))))) + (majutsu-ediff--run-diffedit jj-args))) + +(defun majutsu-ediff--run-diffedit (jj-args) + "Run jj diffedit with JJ-ARGS using Emacs ediff as the diff editor." + (let* ((emacsclient (or (executable-find "emacsclient") + (error "Cannot find emacsclient"))) + (server-name (or server-name "server")) + ;; Build the diff-editor command - use TOML array syntax for proper quoting + (diff-editor-cmd (format "ui.diff-editor=[\"%s\", \"-s\", \"%s\", \"--eval\", \"(majutsu-ediff-directories \\\"$left\\\" \\\"$right\\\")\"]" + emacsclient server-name))) + ;; Use async to avoid blocking Emacs while jj waits for emacsclient + (apply #'majutsu-run-jj-async "diffedit" "--config" diff-editor-cmd jj-args))) + +;;;###autoload +(defun majutsu-ediff-directories (left right) + "Compare LEFT and RIGHT directories 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 (expand-file-name left)) + (right (expand-file-name right))) + ;; Set up quit hook to exit recursive-edit + (add-hook 'ediff-quit-hook #'majutsu-ediff--exit-recursive-edit) + (unwind-protect + (progn + (ediff-directories left right nil) + ;; Block until user quits ediff + (recursive-edit)) + (remove-hook 'ediff-quit-hook #'majutsu-ediff--exit-recursive-edit)))) + +(defun majutsu-ediff--exit-recursive-edit () + "Exit recursive edit when ediff quits." + (when (> (recursion-depth) 0) + (exit-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 +(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" "Dwim" majutsu-ediff-dwim) + ("E" "Edit (modify right)" 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 'transient-option + :key "-r" + :argument "--revisions=" + :multi-value 'repeat + :prompt "Revisions: ") + +(transient-define-argument majutsu-ediff:revisions () + :description "Revisions (toggle at point)" + :class 'majutsu-diff--toggle-range-option + :selection-key 'revisions + :selection-label "[REVS]" + :selection-face '(:background "goldenrod" :foreground "black") + :selection-type 'multi + :locate-fn (##majutsu-section-find % 'jj-commit) + :key "r" + :argument "--revisions=" + :multi-value 'repeat) + +(transient-define-argument majutsu-ediff:--from () + :description "From" + :class 'majutsu-diff--range-option + :selection-key 'from + :selection-label "[FROM]" + :selection-face '(:background "dark orange" :foreground "black") + :selection-type 'single + :locate-fn (##majutsu-section-find % 'jj-commit) + :key "-f" + :argument "--from=" + :reader #'majutsu-ediff--transient-read-revset) + +(transient-define-argument majutsu-ediff:--to () + :description "To" + :class 'majutsu-diff--range-option + :selection-key 'to + :selection-label "[TO]" + :selection-face '(:background "dark cyan" :foreground "white") + :selection-type 'single + :locate-fn (##majutsu-section-find % '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-diff--toggle-range-option + :selection-key 'from + :selection-type 'single + :locate-fn (##majutsu-section-find % 'jj-commit) + :key "f" + :argument "--from=") + +(transient-define-argument majutsu-ediff:to () + :description "To (toggle at point)" + :class 'majutsu-diff--toggle-range-option + :selection-key 'to + :selection-type 'single + :locate-fn (##majutsu-section-find % 'jj-commit) + :key "t" + :argument "--to=") + +;;; _ +(provide 'majutsu-ediff) +;;; majutsu-ediff.el ends here diff --git a/majutsu-evil.el b/majutsu-evil.el index 5fc508f..f98d334 100644 --- a/majutsu-evil.el +++ b/majutsu-evil.el @@ -49,6 +49,26 @@ When nil, Majutsu leaves Evil's state untouched." (symbol :tag "Custom state")) :group 'majutsu-evil) +(defvar majutsu-conflict-evil-before-map + (let ((map (make-sparse-keymap))) + (dotimes (i 9) + (let ((n (1+ i))) + (define-key map (kbd (number-to-string n)) + (lambda () (interactive) + (majutsu-conflict-keep-side n t))))) + map) + "Keymap for selecting the before state in JJ conflicts.") + +(defvar majutsu-conflict-evil-resolve-map + (let ((map (make-sparse-keymap))) + (dotimes (i 9) + (let ((n (1+ i))) + (define-key map (kbd (number-to-string n)) + (lambda () (interactive) + (majutsu-conflict-keep-side n nil))))) + map) + "Keymap for JJ conflict actions under Evil.") + (defun majutsu-evil--define-keys (states keymap &rest bindings) "Define Evil BINDINGS for each state in STATES on KEYMAP. STATES can be a symbol or list. KEYMAP should be a symbol. @@ -122,8 +142,7 @@ This mirrors `evil-collection-magit-adjust-section-bindings'." (kbd "d") #'majutsu-diff (kbd "D") #'majutsu-diff-dwim (kbd "*") #'majutsu-workspace - (kbd "E") #'majutsu-diffedit-emacs - (kbd "M") #'majutsu-diffedit-smerge + (kbd "E") #'majutsu-ediff (kbd "?") #'majutsu-dispatch (kbd "RET") #'majutsu-visit-thing) @@ -149,7 +168,19 @@ This mirrors `evil-collection-magit-adjust-section-bindings'." (kbd ".") #'majutsu-log-goto-@ (kbd "O") #'majutsu-new-dwim (kbd "I") #'majutsu-new-with-before - (kbd "A") #'majutsu-new-with-after)) + (kbd "A") #'majutsu-new-with-after) + + ;; majutsu-conflict-mode is a minor mode + (add-hook 'majutsu-conflict-mode-hook #'evil-normalize-keymaps) + (majutsu-evil--define-keys 'normal 'majutsu-conflict-mode-map + "gj" #'majutsu-conflict-next + "]]" #'majutsu-conflict-next + "gk" #'majutsu-conflict-prev + "[[" #'majutsu-conflict-prev + "gb" #'majutsu-conflict-keep-base + "gr" majutsu-conflict-evil-resolve-map + "gR" majutsu-conflict-evil-before-map + "ge" #'majutsu-conflict-refine)) ;;;###autoload (defun majutsu-evil-setup () diff --git a/majutsu-file.el b/majutsu-file.el index 1537527..8eac397 100644 --- a/majutsu-file.el +++ b/majutsu-file.el @@ -74,6 +74,22 @@ Results are cached in `majutsu-file--list-cache`." (push (cons cache-key paths) majutsu-file--list-cache) paths)))) +(defun majutsu-file-list (&optional revset) + "Return list of file paths for REVSET (default \"@\")." + (majutsu-file--list (or revset "@") (majutsu-file--root))) + +(defun majutsu-read-files (prompt initial-input history &optional list-fn) + "Read multiple files with completion. +PROMPT, INITIAL-INPUT, HISTORY are standard reader args. +LIST-FN defaults to `majutsu-file-list'." + (let ((root (majutsu-file--root))) + (majutsu-completing-read-multiple + prompt + (funcall (or list-fn #'majutsu-file-list)) + nil nil + (or initial-input (majutsu-file--path-at-point root)) + history))) + (defun majutsu-file--show (revset path root) "Return file contents for REVSET and PATH in ROOT as a string." (let ((default-directory root)) @@ -235,6 +251,7 @@ displayed in a single window." majutsu-buffer-blob-root))) (find-file file))) +;; TODO: move path condition to majutsu-file-*-change (defun majutsu-file--revset-for-files (revset path direction) "Build a revset for PATH and DIRECTION relative to REVSET. DIRECTION should be either \='prev or \='next." diff --git a/majutsu-log.el b/majutsu-log.el index e0381a9..678757f 100644 --- a/majutsu-log.el +++ b/majutsu-log.el @@ -24,6 +24,8 @@ (require 'majutsu-section) (require 'json) +(declare-function majutsu-read-files "majutsu-file" (prompt initial-input history &optional list-fn)) + (defcustom majutsu-log-field-faces '((bookmarks . magit-branch-local) (tags . magit-tag) @@ -1047,26 +1049,6 @@ offer to create one using `jj git init`." (majutsu-log--set-value 'majutsu-log-mode args nil filesets)) (majutsu-log-transient--redisplay)) -(defun majutsu-log-transient-add-path () - "Add a fileset/path filter to the log view." - (interactive) - (let* ((input (string-trim (read-from-minibuffer "Add path/pattern: "))) - (paths (caddr (majutsu-log--get-value 'majutsu-log-mode 'direct)))) - (when (and (not (string-empty-p input)) - (not (member input paths))) - (pcase-let ((`(,args ,revsets ,_filesets) - (majutsu-log--get-value 'majutsu-log-mode 'direct))) - (majutsu-log--set-value 'majutsu-log-mode args revsets (append paths (list input)))) - (majutsu-log-transient--redisplay)))) - -(defun majutsu-log-transient-clear-paths () - "Clear all path filters." - (interactive) - (pcase-let ((`(,args ,revsets ,_filesets) - (majutsu-log--get-value 'majutsu-log-mode 'direct))) - (majutsu-log--set-value 'majutsu-log-mode args revsets nil)) - (majutsu-log-transient--redisplay)) - (defun majutsu-log-transient-reset () "Reset log options to defaults." (interactive) @@ -1087,14 +1069,6 @@ offer to create one using `jj git init`." (format "%s (%s)" label value) label)) -(defun majutsu-log--paths-desc () - "Return description for path filters." - (let ((paths (caddr (majutsu-log--get-value 'majutsu-log-mode 'direct)))) - (cond - ((null paths) "Add path filter") - ((= (length paths) 1) (format "Add path filter (%s)" (car paths))) - (t (format "Add path filter (%d paths)" (length paths)))))) - (defun majutsu-log-transient--redisplay () "Redisplay the log transient, compatible with older transient versions." (if (fboundp 'transient-redisplay) @@ -1111,28 +1085,34 @@ offer to create one using `jj git init`." ;;;; Prefix Methods +(cl-defmethod transient-prefix-value ((obj majutsu-log-prefix)) + "Return (args files) from transient value." + (let ((args (cl-call-next-method obj))) + (list (seq-filter #'atom args) + (cdr (assoc "--" args))))) + (cl-defmethod transient-init-value ((obj majutsu-log-prefix)) - (pcase-let ((`(,args ,_revsets ,_filesets) + (pcase-let ((`(,args ,_revsets ,filesets) (majutsu-log--get-value (oref obj major-mode) 'prefix))) - (oset obj value args))) + (oset obj value (if filesets `(("--" ,@filesets) ,@args) args)))) (cl-defmethod transient-set-value ((obj majutsu-log-prefix)) (let* ((obj (oref obj prototype)) - (mode (or (oref obj major-mode) major-mode)) - (args (transient-args (oref obj command)))) - (pcase-let ((`(,_old-args ,revsets ,filesets) + (mode (or (oref obj major-mode) major-mode))) + (pcase-let ((`(,args ,files) (transient-args (oref obj command))) + (`(,_old-args ,revsets ,_filesets) (majutsu-log--get-value mode 'direct))) - (majutsu-log--set-value mode args revsets filesets) + (majutsu-log--set-value mode args revsets files) (transient--history-push obj) (majutsu-refresh)))) (cl-defmethod transient-save-value ((obj majutsu-log-prefix)) (let* ((obj (oref obj prototype)) - (mode (or (oref obj major-mode) major-mode)) - (args (transient-args (oref obj command)))) - (pcase-let ((`(,_old-args ,revsets ,filesets) + (mode (or (oref obj major-mode) major-mode))) + (pcase-let ((`(,args ,files) (transient-args (oref obj command))) + (`(,_old-args ,revsets ,_filesets) (majutsu-log--get-value mode 'direct))) - (majutsu-log--set-value mode args revsets filesets t) + (majutsu-log--set-value mode args revsets files t) (transient--history-push obj) (majutsu-refresh)))) @@ -1155,6 +1135,15 @@ offer to create one using `jj git init`." :key "-G" :argument "--no-graph") +(transient-define-argument majutsu-log:-- () + :description "Limit to filesets" + :class 'transient-files + :key "--" + :argument "--" + :prompt "Limit to filesets" + :reader #'majutsu-read-files + :multi-value t) + ;;;###autoload (transient-define-prefix majutsu-log-transient () "Transient interface for adjusting jj log options." @@ -1178,12 +1167,7 @@ offer to create one using `jj git init`." :transient t) ] ["Paths" - ("a" "Add path filter" majutsu-log-transient-add-path - :description majutsu-log--paths-desc - :transient t) - ("A" "Clear path filters" majutsu-log-transient-clear-paths - :if (lambda () (caddr (majutsu-log--get-value 'majutsu-log-mode 'direct))) - :transient t)] + (majutsu-log:--)] ["Actions" ("g" "buffer" majutsu-log-transient) ("s" "buffer and set defaults" transient-set-and-exit) @@ -1197,7 +1181,9 @@ offer to create one using `jj git init`." (t (unless (derived-mode-p 'majutsu-log-mode) (user-error "Not in a Majutsu log buffer")) - (setq-local majutsu-buffer-log-args (transient-args transient-current-command)) + (pcase-let ((`(,args ,filesets) (transient-args transient-current-command))) + (setq-local majutsu-buffer-log-args args) + (setq-local majutsu-buffer-log-filesets filesets)) (majutsu-refresh-buffer)))) ;;; _ diff --git a/majutsu-new.el b/majutsu-new.el index a1ba411..832a1ae 100644 --- a/majutsu-new.el +++ b/majutsu-new.el @@ -134,19 +134,6 @@ With prefix ARG, open the new transient for interactive selection." :argument "--insert-before=" :multi-value 'repeat) -(defun majutsu-new-clear-selections () - "Clear all jj new selections." - (interactive) - (when (consp transient--suffixes) - (dolist (obj transient--suffixes) - (when (and (cl-typep obj 'majutsu-new-option) - (memq (oref obj selection-key) '(parent after before))) - (transient-infix-set obj nil)))) - (when transient--prefix - (transient--redisplay)) - (majutsu-selection-render) - (message "Cleared all jj new selections")) - (defun majutsu-new--run-command (args) "Execute jj new with ARGS and refresh the log on success. When called from a blob buffer, also visit the workspace file." @@ -230,7 +217,7 @@ a jj-commit section, add -r from that section." (majutsu-new:parent) (majutsu-new:after) (majutsu-new:before) - ("c" "Clear selections" majutsu-new-clear-selections + ("c" "Clear selections" majutsu-selection-clear :transient t)] ["Options" (majutsu-new-infix-message) diff --git a/majutsu-rebase.el b/majutsu-rebase.el index f56cf56..f08e07b 100644 --- a/majutsu-rebase.el +++ b/majutsu-rebase.el @@ -172,19 +172,6 @@ ARGS are passed from the transient." :argument "--insert-before=" :multi-value 'repeat) -(defun majutsu-rebase-clear-selections () - "Clear all rebase selections." - (interactive) - (when (consp transient--suffixes) - (dolist (obj transient--suffixes) - (when (and (cl-typep obj 'majutsu-rebase-option) - (memq (oref obj selection-key) '(source branch revisions onto after before))) - (transient-infix-set obj nil)))) - (when transient--prefix - (transient--redisplay)) - (majutsu-selection-render) - (message "Cleared all rebase selections")) - (transient-define-prefix majutsu-rebase () "Internal transient for jj rebase operations." :man-page "jj-rebase" @@ -210,7 +197,7 @@ ARGS are passed from the transient." (majutsu-rebase:onto) (majutsu-rebase:after) (majutsu-rebase:before) - ("c" "Clear selections" majutsu-rebase-clear-selections + ("c" "Clear selections" majutsu-selection-clear :transient t)] ["Options" ("-ke" "Skip emptied" "--skip-emptied") diff --git a/majutsu-restore.el b/majutsu-restore.el index b974aa1..8e29879 100644 --- a/majutsu-restore.el +++ b/majutsu-restore.el @@ -15,6 +15,7 @@ ;;; Code: (require 'majutsu) +(require 'majutsu-file) (require 'majutsu-selection) (require 'majutsu-interactive) (require 'majutsu-section) @@ -45,9 +46,6 @@ ;;; Restore -(defvar-local majutsu-restore--filesets nil - "Filesets for the current restore operation.") - (defun majutsu-restore--default-args () "Return default args from diff buffer context." (with-current-buffer (majutsu-interactive--selection-buffer) @@ -148,49 +146,14 @@ In diff buffer on a file section, restore only that file." :key "c" :argument "--changes-in=") -(defun majutsu-restore-clear-selections () - "Clear all restore selections." - (interactive) - (when (consp transient--suffixes) - (dolist (obj transient--suffixes) - (when (and (cl-typep obj 'majutsu-restore-option) - (memq (oref obj selection-key) '(from to changes-in))) - (transient-infix-set obj nil)))) - (setq majutsu-restore--filesets nil) - (when transient--prefix - (transient--redisplay)) - (majutsu-selection-render) - (message "Cleared all restore selections")) - -(defun majutsu-restore--read-filesets (prompt &optional initial) - "Read filesets with PROMPT and INITIAL value." - (let ((input (read-string prompt (or initial "")))) - (if (string-empty-p input) - nil - (split-string input)))) - -(defun majutsu-restore-set-filesets () - "Set filesets for restore." - (interactive) - (let* ((current (string-join (or majutsu-restore--filesets '()) " ")) - (new (majutsu-restore--read-filesets "Filesets (space-separated): " current))) - (setq majutsu-restore--filesets new) - (when transient--prefix - (transient--redisplay)) - (message "Filesets: %s" (or (string-join new " ") "(all)")))) - -(defun majutsu-restore--filesets-description () - "Return description for filesets display." - (if majutsu-restore--filesets - (format "Paths: %s" (string-join majutsu-restore--filesets " ")) - "Paths: (all)")) - -(defun majutsu-restore--description () - "Return transient description with context info." - (let ((parts (list "JJ Restore"))) - (when majutsu-restore--filesets - (push (format "paths: %s" (string-join majutsu-restore--filesets " ")) parts)) - (string-join (nreverse parts) " | "))) +(transient-define-argument majutsu-restore:-- () + :description "Limit to files" + :class 'transient-files + :key "--" + :argument "--" + :prompt "Limit to file,s: " + :reader #'majutsu-read-files + :multi-value t) ;;; Prefix @@ -201,7 +164,7 @@ In diff buffer on a file section, restore only that file." ("--to=" "--changes-in=")) :transient-non-suffix t [ - :description majutsu-restore--description + :description "JJ Restore" ["Selection" (majutsu-restore:--from) (majutsu-restore:--to) @@ -209,14 +172,14 @@ In diff buffer on a file section, restore only that file." (majutsu-restore:from) (majutsu-restore:to) (majutsu-restore:changes-in) - ("x" "Clear selections" majutsu-restore-clear-selections :transient t)] + ("x" "Clear selections" majutsu-selection-clear :transient t)] ["Patch Selection" :if majutsu-interactive-selection-available-p (majutsu-interactive:select-hunk) (majutsu-interactive:select-file) (majutsu-interactive:select-region) ("C" "Clear patch selections" majutsu-interactive-clear :transient t)] ["Paths" :if-not majutsu-interactive-selection-available-p - ("p" majutsu-restore--filesets-description majutsu-restore-set-filesets :transient t)] + (majutsu-restore:--)] ["Options" ("-i" "Interactive" "--interactive") ("-d" "Restore descendants" "--restore-descendants") @@ -225,19 +188,19 @@ In diff buffer on a file section, restore only that file." ("r" "Restore" majutsu-restore-execute) ("q" "Quit" transient-quit-one)]] (interactive) - ;; Initialize from context - (let ((file (majutsu-file-at-point))) - ;; Set filesets from context - (setq majutsu-restore--filesets - (cond - (file (list file)) - ((and (derived-mode-p 'majutsu-diff-mode) majutsu-buffer-diff-filesets) - majutsu-buffer-diff-filesets) - (t nil))) + (let* ((file (majutsu-file-at-point)) + (files (cond + (file (list file)) + ((and (derived-mode-p 'majutsu-diff-mode) majutsu-buffer-diff-filesets) + majutsu-buffer-diff-filesets))) + (default-args (majutsu-restore--default-args)) + (value (if files + (append default-args (list (cons "--" files))) + default-args))) (transient-setup 'majutsu-restore nil nil :scope (majutsu-selection-session-begin) - :value (or (majutsu-restore--default-args) '())))) + :value value))) ;;; _ (provide 'majutsu-restore) diff --git a/majutsu-split.el b/majutsu-split.el index 6f3276e..ef90e59 100644 --- a/majutsu-split.el +++ b/majutsu-split.el @@ -15,6 +15,7 @@ ;;; Code: (require 'majutsu) +(require 'majutsu-file) (require 'majutsu-selection) (require 'majutsu-interactive) @@ -24,9 +25,6 @@ (defclass majutsu-split--toggle-option (majutsu-selection-toggle-option) ()) -(defvar-local majutsu-split--filesets nil - "Filesets for the current split operation.") - (defun majutsu-split--default-args () "Return default args from diff buffer context." (with-current-buffer (majutsu-interactive--selection-buffer) @@ -43,13 +41,12 @@ ;; Generate patch for SELECTED content (invert=nil) ;; This is what goes into the first commit (patch (majutsu-interactive-build-patch-if-selected selection-buf nil nil)) - (filesets majutsu-split--filesets) (args (if patch (seq-remove (lambda (arg) (or (string= arg "--interactive") (string-prefix-p "--tool=" arg))) args) - (append args filesets)))) + args))) (if patch (progn ;; reverse=t means reset $right to $left, then apply patch forward @@ -149,42 +146,14 @@ :argument "--insert-before=" :multi-value 'repeat) -(defun majutsu-split-clear-selections () - "Clear all split selections." - (interactive) - (when (consp transient--suffixes) - (dolist (obj transient--suffixes) - (when (and (cl-typep obj 'majutsu-split-option) - (memq (oref obj selection-key) '(revision onto after before))) - (transient-infix-set obj nil)))) - (setq majutsu-split--filesets nil) - (when transient--prefix - (transient--redisplay)) - (majutsu-selection-render) - (message "Cleared all split selections")) - -(defun majutsu-split--read-filesets (prompt &optional initial) - "Read filesets with PROMPT and INITIAL value." - (let ((input (read-string prompt (or initial "")))) - (if (string-empty-p input) - nil - (split-string input)))) - -(defun majutsu-split-set-filesets () - "Set filesets for split." - (interactive) - (let* ((current (string-join (or majutsu-split--filesets '()) " ")) - (new (majutsu-split--read-filesets "Filesets (space-separated): " current))) - (setq majutsu-split--filesets new) - (when transient--prefix - (transient--redisplay)) - (message "Filesets: %s" (or (string-join new " ") "(all)")))) - -(defun majutsu-split--filesets-description () - "Return description for filesets display." - (if majutsu-split--filesets - (format "Paths: %s" (string-join majutsu-split--filesets " ")) - "Paths: (all)")) +(transient-define-argument majutsu-split:-- () + :description "Limit to files" + :class 'transient-files + :key "--" + :argument "--" + :prompt "Limit to file,s: " + :reader #'majutsu-read-files + :multi-value t) ;;;; Prefix @@ -203,14 +172,14 @@ (majutsu-split:onto) (majutsu-split:insert-after) (majutsu-split:insert-before) - ("c" "Clear selections" majutsu-split-clear-selections :transient t)] + ("c" "Clear selections" majutsu-selection-clear :transient t)] ["Patch Selection" :if majutsu-interactive-selection-available-p (majutsu-interactive:select-hunk) (majutsu-interactive:select-file) (majutsu-interactive:select-region) ("C" "Clear patch selections" majutsu-interactive-clear :transient t)] ["Paths" :if-not majutsu-interactive-selection-available-p - ("p" majutsu-split--filesets-description majutsu-split-set-filesets :transient t)] + (majutsu-split:--)] ["Options" ("-i" "Interactive" "--interactive") ("-p" "Parallel" "--parallel") diff --git a/majutsu-squash.el b/majutsu-squash.el index 7fd6a10..cf91ea5 100644 --- a/majutsu-squash.el +++ b/majutsu-squash.el @@ -79,6 +79,16 @@ a jj-commit section, add --revision from that section." :argument "--revision=" :reader #'majutsu-diff--transient-read-revset) +(transient-define-argument majutsu-squash:revision () + :description "Revision (toggle at point)" + :class 'majutsu-squash--toggle-option + :selection-key 'revision + :selection-label "[REV]" + :selection-face '(:background "goldenrod" :foreground "black") + :selection-type 'single + :key "r" + :argument "--revision=") + (transient-define-argument majutsu-squash:--from () :description "From" :class 'majutsu-squash-option @@ -190,19 +200,6 @@ a jj-commit section, add --revision from that section." :argument "--insert-before=" :multi-value 'repeat) -(defun majutsu-squash-clear-selections () - "Clear all squash selections." - (interactive) - (when (consp transient--suffixes) - (dolist (obj transient--suffixes) - (when (and (cl-typep obj 'majutsu-squash-option) - (memq (oref obj selection-key) '(revision from into onto after before))) - (transient-infix-set obj nil)))) - (when transient--prefix - (transient--redisplay)) - (majutsu-selection-render) - (message "Cleared all squash selections")) - ;;;; Prefix (transient-define-prefix majutsu-squash () @@ -221,12 +218,13 @@ a jj-commit section, add --revision from that section." (majutsu-squash:--onto) (majutsu-squash:--insert-after) (majutsu-squash:--insert-before) + (majutsu-squash:revision) (majutsu-squash:from) (majutsu-squash:into) (majutsu-squash:onto) (majutsu-squash:insert-after) (majutsu-squash:insert-before) - ("c" "Clear selections" majutsu-squash-clear-selections :transient t)] + ("c" "Clear selections" majutsu-selection-clear :transient t)] ["Patch Selection" :if majutsu-interactive-selection-available-p (majutsu-interactive:select-hunk) (majutsu-interactive:select-file) diff --git a/majutsu.el b/majutsu.el index f3bf1c5..360218f 100644 --- a/majutsu.el +++ b/majutsu.el @@ -45,10 +45,9 @@ Instead of invoking this alias for `majutsu-log' using ("d" "Diff" majutsu-diff) ("D" "Diff (dwim)" majutsu-diff-dwim) ("e" "Edit change" majutsu-edit-changeset) - ("E" "DiffEdit (ediff)" majutsu-diffedit-emacs)] + ("E" "Ediff" majutsu-ediff)] [("G" "Git" majutsu-git-transient) ("l" "Log options" majutsu-log-transient) - ("M" "DiffEdit (smerge)" majutsu-diffedit-smerge) ("o" "New" majutsu-new) ("O" "New (dwim)" majutsu-new-dwim) ("r" "Rebase" majutsu-rebase) @@ -72,6 +71,7 @@ Instead of invoking this alias for `majutsu-log' using (cl-eval-when (load eval) (require 'majutsu-log) + (require 'majutsu-ediff) (require 'majutsu-bookmark) (require 'majutsu-duplicate) (require 'majutsu-edit) @@ -86,7 +86,8 @@ Instead of invoking this alias for `majutsu-log' using (require 'majutsu-commit) (require 'majutsu-new) (require 'majutsu-op) - (require 'majutsu-workspace)) + (require 'majutsu-workspace) + (require 'majutsu-conflict)) (with-eval-after-load 'evil (require 'majutsu-evil nil t)) diff --git a/test/majutsu-conflict-test.el b/test/majutsu-conflict-test.el new file mode 100644 index 0000000..1c146af --- /dev/null +++ b/test/majutsu-conflict-test.el @@ -0,0 +1,385 @@ +;;; majutsu-conflict-test.el --- Tests for majutsu-conflict -*- lexical-binding: t; -*- + +;;; Commentary: + +;; Tests for conflict marker parsing. + +;;; Code: + +(require 'ert) + +(defconst majutsu-conflict-test--root + (locate-dominating-file (or (getenv "PWD") default-directory) + "majutsu-conflict.el") + "Root directory for Majutsu tests.") + +(when majutsu-conflict-test--root + (add-to-list 'load-path majutsu-conflict-test--root) + (load (expand-file-name "majutsu-conflict.el" majutsu-conflict-test--root) nil t)) + +(require 'majutsu-conflict) + +;;; Test Data + +(defconst majutsu-conflict-test--jj-diff + "some text before +<<<<<<< conflict 1 of 1 +%%%%%%% diff from: vpxusssl 38d49363 \"merge base\" +\\\\\\\\\\ to: rtsqusxu 2768b0b9 \"commit A\" + apple +-grape ++grapefruit + orange ++++++++ ysrnknol 7a20f389 \"commit B\" +APPLE +GRAPE +ORANGE +>>>>>>> conflict 1 of 1 ends +some text after +" + "Sample JJ diff-style conflict.") + +(defconst majutsu-conflict-test--jj-diff-long + "<<<<<<< conflict 1 of 1 +%%%%%%% diff from: utzqqyqr d1e4c728 \"snapshot refine\" (parents of rebased revision) +\\\\\\\\\\ to: utzqqyqr 3e1a7f5b \"snapshot refine\" (rebase destination) + (majutsu-evil--define-keys '(normal visual) 'majutsu-diff-mode-map + (kbd \"g d\") #'majutsu-jump-to-diffstat-or-diff + (kbd \"C-\") #'majutsu-diff-visit-workspace-file) + (majutsu-evil--define-keys '(normal visual motion) 'majutsu-blob-mode-map + (kbd \"p\") #'majutsu-blob-previous + (kbd \"n\") #'majutsu-blob-next + (kbd \"q\") #'majutsu-blob-quit +- (kbd \"V\") #'majutsu-blob-visit-file ++ ;; (kbd \"V\") #'majutsu-blob-visit-file + (kbd \"b\") #'majutsu-annotate-addition + ;; RET visits the revision (edit) + (kbd \"RET\") #'majutsu-edit-changeset) ++++++++ ntknkwlu 1c8cca65 \"X | refactor majutsu-evil\" (rebased revision) + (define-key map (kbd (number-to-string n)) + (lambda () (interactive) + (majutsu-conflict-keep-side n nil))))) + map) + \"Keymap for JJ conflict actions under Evil.\") + (when (and (featurep 'evil) majutsu-evil-enable-integration) + (majutsu-evil--define-keys 'normal 'majutsu-conflict-mode-map + (kbd \"g j\") #'majutsu-conflict-next + (kbd \"] ]\") #'majutsu-conflict-next + (kbd \"g k\") #'majutsu-conflict-prev + (kbd \"[ [\") #'majutsu-conflict-prev + (kbd \"g b\") #'majutsu-conflict-keep-base + (kbd \"g r\") majutsu-conflict-evil-resolve-map + (kbd \"g R\") majutsu-conflict-evil-before-map))) +>>>>>>> conflict 1 of 1 ends +" + "JJ diff-style conflict with longer content.") + + +(defconst majutsu-conflict-test--jj-snapshot + "<<<<<<< conflict 1 of 1 ++++++++ rtsqusxu 2768b0b9 \"commit A\" +apple +grapefruit +orange +------- vpxusssl 38d49363 \"merge base\" +apple +grape +orange ++++++++ ysrnknol 7a20f389 \"commit B\" +APPLE +GRAPE +ORANGE +>>>>>>> conflict 1 of 1 ends +" + "Sample JJ snapshot-style conflict.") + +(defconst majutsu-conflict-test--git + "<<<<<<< rtsqusxu 2768b0b9 \"commit A\" +apple +grapefruit +orange +||||||| vpxusssl 38d49363 \"merge base\" +apple +grape +orange +======= +APPLE +GRAPE +ORANGE +>>>>>>> ysrnknol 7a20f389 \"commit B\" +" + "Sample Git-style conflict.") + +;;; Tests + +(ert-deftest majutsu-conflict-test-parse-jj-diff () + "Test parsing JJ diff-style conflict." + (with-temp-buffer + (insert majutsu-conflict-test--jj-diff) + (let ((conflicts (majutsu-conflict-parse-buffer))) + (should (= 1 (length conflicts))) + (let ((c (car conflicts))) + (should (eq 'jj-diff (majutsu-conflict-style c))) + (should (= 1 (length (majutsu-conflict-removes c)))) + (should (= 1 (length (majutsu-conflict-adds c)))) + (should (majutsu-conflict-base c)))))) + +(ert-deftest majutsu-conflict-test-parse-jj-snapshot () + "Test parsing JJ snapshot-style conflict." + (with-temp-buffer + (insert majutsu-conflict-test--jj-snapshot) + (let ((conflicts (majutsu-conflict-parse-buffer))) + (should (= 1 (length conflicts))) + (let ((c (car conflicts))) + (should (eq 'jj-snapshot (majutsu-conflict-style c))) + (should (= 1 (length (majutsu-conflict-removes c)))) + (should (= 1 (length (majutsu-conflict-adds c)))) + (should (majutsu-conflict-base c)))))) + +(ert-deftest majutsu-conflict-test-parse-git () + "Test parsing Git-style conflict." + (with-temp-buffer + (insert majutsu-conflict-test--git) + (let ((conflicts (majutsu-conflict-parse-buffer))) + (should (= 1 (length conflicts))) + (let ((c (car conflicts))) + (should (eq 'git (majutsu-conflict-style c))) + (should (= 1 (length (majutsu-conflict-removes c)))) + (should (= 2 (length (majutsu-conflict-adds c)))))))) + +(ert-deftest majutsu-conflict-test-at-point () + "Test finding conflict at point." + (with-temp-buffer + (insert majutsu-conflict-test--jj-diff) + (goto-char (point-min)) + (should-not (majutsu-conflict-at-point)) + (search-forward "<<<<<<<") + (should (majutsu-conflict-at-point)) + (goto-char (point-max)) + (should-not (majutsu-conflict-at-point)))) + +(ert-deftest majutsu-conflict-test-navigation () + "Test conflict navigation." + (with-temp-buffer + (insert majutsu-conflict-test--jj-diff) + (insert "\n") + (insert majutsu-conflict-test--git) + (goto-char (point-min)) + (majutsu-conflict-next) + (should (looking-at "<<<<<<<")) + (majutsu-conflict-next) + (should (looking-at "<<<<<<<")) + (majutsu-conflict-prev) + (should (looking-at "<<<<<<<")))) + +(defun majutsu-conflict-test--face-at-line () + "Get the face at the beginning of current line. +Handles both single face and face list." + (let ((face (get-text-property (line-beginning-position) 'face))) + (if (listp face) (car face) face))) + +(ert-deftest majutsu-conflict-test-font-lock-keywords-added () + "Test that font-lock keywords are added." + (with-temp-buffer + (insert majutsu-conflict-test--jj-diff) + (fundamental-mode) + (font-lock-mode 1) + (majutsu-conflict-mode 1) + ;; Check keywords are added + (should (cl-find 'majutsu-conflict--find-conflict font-lock-keywords + :key (lambda (x) (if (consp x) (car x))))))) + +(ert-deftest majutsu-conflict-test-match-line () + "Test that match-line matcher works." + (with-temp-buffer + (insert majutsu-conflict-test--jj-diff) + (goto-char (point-min)) + ;; Find conflict first + (majutsu-conflict--find-conflict nil) + (let ((conflict-end (match-end 0))) + ;; Reset to conflict start + (goto-char (match-beginning 0)) + (setq majutsu-conflict--in-diff nil + majutsu-conflict--in-add nil + majutsu-conflict--in-remove nil) + ;; First line should be <<<<<<< + (should (majutsu-conflict--match-line conflict-end)) + (should (match-beginning 0))))) + +(ert-deftest majutsu-conflict-test-font-lock-jj-diff () + "Test font-lock highlighting for JJ diff-style conflict." + (with-temp-buffer + (insert majutsu-conflict-test--jj-diff) + (fundamental-mode) + (font-lock-mode 1) + (majutsu-conflict-mode 1) + (font-lock-ensure) + ;; Check marker line + (goto-char (point-min)) + (search-forward "<<<<<<<") + (should (eq (majutsu-conflict-test--face-at-line) + 'majutsu-conflict-marker-face)) + ;; Check diff marker + (search-forward "%%%%%%%") + (should (eq (majutsu-conflict-test--face-at-line) + 'majutsu-conflict-marker-face)) + ;; Check removed line + (search-forward "-grape") + (should (eq (majutsu-conflict-test--face-at-line) + 'majutsu-conflict-removed-face)) + ;; Check added line + (search-forward "+grapefruit") + (should (eq (majutsu-conflict-test--face-at-line) + 'majutsu-conflict-added-face)) + ;; Check context line + (search-forward " orange") + (should (eq (majutsu-conflict-test--face-at-line) + 'majutsu-conflict-context-face)) + ;; Check base content (+++++++ section in jj-diff) + (search-forward "APPLE") + (should (eq (majutsu-conflict-test--face-at-line) + 'majutsu-conflict-base-face)))) + +(ert-deftest majutsu-conflict-test-font-lock-jj-diff-long () + "Test font-lock highlighting for JJ diff-style conflict with longer content." + (with-temp-buffer + (insert majutsu-conflict-test--jj-diff-long) + (fundamental-mode) + (font-lock-mode 1) + (majutsu-conflict-mode 1) + (font-lock-ensure) + ;; Check marker line + (goto-char (point-min)) + (search-forward "<<<<<<<") + (should (eq (majutsu-conflict-test--face-at-line) + 'majutsu-conflict-marker-face)) + ;; Check diff marker + (search-forward "%%%%%%%") + (should (eq (majutsu-conflict-test--face-at-line) + 'majutsu-conflict-marker-face)) + ;; Check removed line + (search-forward "- (kbd \"V\")") + (should (eq (majutsu-conflict-test--face-at-line) + 'majutsu-conflict-removed-face)) + ;; Check added line + (search-forward "+ ;; (kbd \"V\")") + (should (eq (majutsu-conflict-test--face-at-line) + 'majutsu-conflict-added-face)) + ;; Check base content (+++++++ section in jj-diff) + (search-forward "define-key map") + (should (eq (majutsu-conflict-test--face-at-line) + 'majutsu-conflict-base-face)))) + +(ert-deftest majutsu-conflict-test-font-lock-jj-snapshot () + "Test font-lock highlighting for JJ snapshot-style conflict." + (with-temp-buffer + (insert majutsu-conflict-test--jj-snapshot) + (fundamental-mode) + (font-lock-mode 1) + (majutsu-conflict-mode 1) + (font-lock-ensure) + ;; First +++++++ is base + (goto-char (point-min)) + (search-forward "grapefruit") + (should (eq (majutsu-conflict-test--face-at-line) + 'majutsu-conflict-base-face)) + ;; ------- is "from" side (removed) + (search-forward "grape\n") + (backward-char 2) + (should (eq (majutsu-conflict-test--face-at-line) + 'majutsu-conflict-removed-face)) + ;; Second +++++++ is "to" side (added) + (search-forward "GRAPE") + (should (eq (majutsu-conflict-test--face-at-line) + 'majutsu-conflict-added-face)))) + +(ert-deftest majutsu-conflict-test-overlay-cleanup () + "Test that refine overlays are cleaned up." + (with-temp-buffer + (insert majutsu-conflict-test--jj-diff) + ;; Manually create test overlays with majutsu-conflict-refine property + (let ((ov1 (make-overlay 10 20)) + (ov2 (make-overlay 30 40))) + (overlay-put ov1 'majutsu-conflict-refine t) + (overlay-put ov2 'majutsu-conflict-refine t)) + ;; Verify overlays exist + (should (= 2 (length (cl-remove-if-not + (lambda (ov) + (overlay-get ov 'majutsu-conflict-refine)) + (overlays-in (point-min) (point-max)))))) + ;; Clear overlays + (majutsu-conflict--clear-overlays) + ;; Verify all refine overlays are removed + (should-not (cl-some (lambda (ov) + (overlay-get ov 'majutsu-conflict-refine)) + (overlays-in (point-min) (point-max)))))) + +(ert-deftest majutsu-conflict-test-refine-snapshot () + "Test word-level refinement for jj-snapshot style." + (with-temp-buffer + (insert "<<<<<<< conflict ++++++++ base +apple +------- from +apple ++++++++ to +apricot +>>>>>>> end +") + (majutsu-conflict--refine-snapshots) + ;; Should have refine overlays + (let ((refine-ovs (cl-remove-if-not + (lambda (ov) + (overlay-get ov 'majutsu-conflict-refine)) + (overlays-in (point-min) (point-max))))) + (should (> (length refine-ovs) 0))))) + +(ert-deftest majutsu-conflict-test-context-updates-after-edit () + "Test that context face updates after edits in diff section." + (with-temp-buffer + (insert majutsu-conflict-test--jj-diff) + (fundamental-mode) + (font-lock-mode 1) + (majutsu-conflict-mode 1) + (font-lock-ensure) + ;; Insert a context line inside the diff section. + (goto-char (point-min)) + (search-forward "+grapefruit") + (forward-line 1) + (insert " new-context\n") + (forward-line -1) + (font-lock-ensure (line-beginning-position) (line-end-position)) + (should (eq (majutsu-conflict-test--face-at-line) + 'majutsu-conflict-context-face)))) + +(ert-deftest majutsu-conflict-test-mode-does-not-modify-buffer () + "Test that enabling majutsu-conflict-mode does not mark buffer as modified." + (with-temp-buffer + (insert majutsu-conflict-test--jj-diff) + (set-buffer-modified-p nil) + (fundamental-mode) + (font-lock-mode 1) + (majutsu-conflict-mode 1) + (font-lock-ensure) + (should-not (buffer-modified-p)))) + +(ert-deftest majutsu-conflict-test-ensure-mode-jj () + "Test that JJ conflicts enable majutsu-conflict-mode." + (with-temp-buffer + (insert majutsu-conflict-test--jj-diff) + (fundamental-mode) + (majutsu-conflict-ensure-mode) + (should majutsu-conflict-mode) + (should-not smerge-mode))) + +(ert-deftest majutsu-conflict-test-ensure-mode-git () + "Test that Git conflicts enable smerge-mode." + (with-temp-buffer + (insert majutsu-conflict-test--git) + (fundamental-mode) + (majutsu-conflict-ensure-mode) + (should smerge-mode) + (should-not majutsu-conflict-mode))) + +(provide 'majutsu-conflict-test) +;;; majutsu-conflict-test.el ends here diff --git a/test/majutsu-ediff-test.el b/test/majutsu-ediff-test.el new file mode 100644 index 0000000..901b24b --- /dev/null +++ b/test/majutsu-ediff-test.el @@ -0,0 +1,67 @@ +;;; majutsu-ediff-test.el --- Tests for majutsu-ediff -*- lexical-binding: t; -*- + +;;; Commentary: + +;; Tests for ediff integration. + +;;; Code: + +(require 'ert) + +(defconst majutsu-ediff-test--root + (file-name-directory + (directory-file-name + (file-name-directory + (or load-file-name buffer-file-name + (locate-library "majutsu-ediff.el")))))) + +(when majutsu-ediff-test--root + (add-to-list 'load-path majutsu-ediff-test--root) + (load (expand-file-name "majutsu-ediff.el" majutsu-ediff-test--root) nil t)) + +(require 'majutsu-ediff) + +;;; Tests + +(ert-deftest majutsu-ediff-test-parse-diff-range-revisions () + "Test parsing --revisions= format." + (let ((result (majutsu-ediff--parse-diff-range '("--revisions=abc")))) + (should (equal (car result) "abc-")) + (should (equal (cdr result) "abc")))) + +(ert-deftest majutsu-ediff-test-parse-diff-range-from-to () + "Test parsing --from/--to format." + (let ((result (majutsu-ediff--parse-diff-range '("--from=foo" "--to=bar")))) + (should (equal (car result) "foo")) + (should (equal (cdr result) "bar")))) + +(ert-deftest majutsu-ediff-test-parse-diff-range-from-only () + "Test parsing --from only." + (let ((result (majutsu-ediff--parse-diff-range '("--from=foo")))) + (should (equal (car result) "foo")) + (should (equal (cdr result) "@")))) + +(ert-deftest majutsu-ediff-test-parse-diff-range-to-only () + "Test parsing --to only." + (let ((result (majutsu-ediff--parse-diff-range '("--to=bar")))) + (should (equal (car result) "@-")) + (should (equal (cdr result) "bar")))) + +(ert-deftest majutsu-ediff-test-parse-diff-range-nil () + "Test parsing nil range." + (let ((result (majutsu-ediff--parse-diff-range nil))) + (should (null result)))) + +(ert-deftest majutsu-ediff-test-parse-diff-range-empty () + "Test parsing empty range." + (let ((result (majutsu-ediff--parse-diff-range '()))) + (should (null result)))) + +(ert-deftest majutsu-ediff-test-parse-diff-range-short-r () + "Test parsing -r format." + (let ((result (majutsu-ediff--parse-diff-range '("-rxyz")))) + (should (equal (car result) "xyz-")) + (should (equal (cdr result) "xyz")))) + +(provide 'majutsu-ediff-test) +;;; majutsu-ediff-test.el ends here