# @+leo-ver=5-thin
# @+node:ekr.20150514041209.1: * @file ../commands/editFileCommands.py
"""Leo's file-editing commands."""

# @+<< editFileCommands imports & annotations >>
# @+node:ekr.20170806094317.4: ** << editFileCommands imports & annotations >>
from __future__ import annotations
from collections.abc import Callable
import difflib
import os
import re
from typing import Any, TYPE_CHECKING
from leo.core import leoGlobals as g
from leo.core import leoCommands
from leo.commands.baseCommands import BaseEditCommandsClass

if TYPE_CHECKING:  # pragma: no cover
    from leo.core.leoCommands import Commands as Cmdr
    from leo.core.leoGui import LeoKeyEvent
    from leo.core.leoNodes import Position, VNode
# @-<< editFileCommands imports & annotations >>


def cmd(name: str) -> Callable:
    """Command decorator for the EditFileCommandsClass class."""
    return g.new_cmd_decorator(name, ['c', 'editFileCommands'])


# @+others
# @+node:ekr.20210307060752.1: ** class ConvertAtRoot
class ConvertAtRoot:
    """
    A class to convert @root directives to @clean nodes:

    - Change @root directive in body to @clean in the headline.
    - Make clones of section references defined outside of @clean nodes,
      moving them so they are children of the nodes that reference them.
    """

    errors = 0
    root = None  # Root of @root tree.
    root_pat = re.compile(r'^@root\s+(.+)$', re.MULTILINE)
    section_pat = re.compile(r'\s*<\<(.+)>\>')
    units: list[Position] = []  # List of positions containing @unit.

    # @+others
    # @+node:ekr.20210308044128.1: *3* atRoot.check_move
    def check_clone_move(self, p: Position, parent: Position) -> bool:
        """
        Return False if p or any of p's descendants is a clone of parent
        or any of parents ancestors.
        """
        # Like as checkMoveWithParentWithWarning without warning.
        clonedVnodes = {}
        for ancestor in parent.self_and_parents(copy=False):
            if ancestor.isCloned():
                v = ancestor.v
                clonedVnodes[v] = v
        if not clonedVnodes:
            return True
        for p in p.self_and_subtree(copy=False):
            if p.isCloned() and clonedVnodes.get(p.v):
                return False
        return True

    # @+node:ekr.20210307060752.2: *3* atRoot.convert_file
    def convert_file(self, c: Cmdr) -> None:
        """Convert @root to @clean in the the .leo file at the given path."""
        self.find_all_units(c)
        for p in c.all_positions():
            m = self.root_pat.search(p.b)
            if path := m and m.group(1):
                # Weird special case. Don't change section definition!
                if self.section_pat.match(p.h):
                    print(f"\nCan not create @clean node: {p.h}\n")
                    self.errors += 1
                else:
                    self.root = p.copy()
                    p.h = f"@clean {path}"
                self.do_root(p)
                self.root = None

        # Check the results.
        link_errors = c.checkOutline()
        self.errors += link_errors
        print(f"{self.errors} error{g.plural(self.errors)} in {c.shortFileName()}")
        c.redraw()
        # if not self.errors: self.dump(c)

    # @+node:ekr.20210308045306.1: *3* atRoot.dump
    def dump(self, c: Cmdr) -> None:
        print(f"Dump of {c.shortFileName()}...")
        for p in c.all_positions():
            print(' ' * 2 * p.level(), p.h)

    # @+node:ekr.20210307075117.1: *3* atRoot.do_root
    def do_root(self, p: Position) -> None:
        """
        Make all necessary clones for section definitions.
        """
        for p in p.self_and_subtree():
            self.make_clones(p)

    # @+node:ekr.20210307085034.1: *3* atRoot.find_all_units
    def find_all_units(self, c: Cmdr) -> None:
        """Scan for all @unit nodes."""
        for p in c.all_positions():
            if '@unit' in p.b:
                self.units.append(p.copy())

    # @+node:ekr.20210307082125.1: *3* atRoot.find_section
    def find_section(self, root: Position, section_name: str) -> Position | None:
        """Find the section definition node in root's subtree for the given section."""

        def munge(s: str) -> str:
            return s.strip().replace(' ', '').lower()

        for p in root.subtree():
            if munge(p.h).startswith(munge(section_name)):
                return p
        return None

    # @+node:ekr.20210307075325.1: *3* atRoot.make_clones
    def make_clones(self, p: Position) -> None:
        """Make clones for all undefined sections in p.b."""
        for s in g.splitLines(p.b):
            if m := self.section_pat.match(s):
                section_name = g.angleBrackets(m.group(1).strip())
                section_p = self.make_clone(p, section_name)
                if not section_p:
                    print(f"MISSING: {section_name:30} {p.h}")
                    self.errors += 1

    # @+node:ekr.20210307080500.1: *3* atRoot.make_clone
    def make_clone(self, p: Position, section_name: str) -> Position | None:
        """Make c clone for section, if necessary."""

        def clone_and_move(parent: Position, section_p: Position) -> None:
            clone = section_p.clone()
            if self.check_clone_move(clone, parent):
                print(f"  CLONE: {section_p.h:30} parent: {parent.h}")
                clone.moveToLastChildOf(parent)
            else:
                print(f"Can not clone: {section_p.h:30} parent: {parent.h}")
                clone.doDelete()
                self.errors += 1

        # First, look in p's subtree.
        if section_p := self.find_section(p, section_name):
            return section_p

        # Finally, look in the @unit tree.
        for unit_p in self.units:
            if section_p := self.find_section(unit_p, section_name):
                clone_and_move(p, section_p)
                return section_p
        return None

    # @-others


# @+node:ekr.20170806094319.14: ** class EditFileCommandsClass
class EditFileCommandsClass(BaseEditCommandsClass):
    """A class to load files into buffers and save buffers to files."""

    # @+others
    # @+node:ekr.20210308051724.1: *3* efc.convert-at-root
    @cmd('convert-at-root')
    def convert_at_root(self, event: LeoKeyEvent | None = None) -> None:
        # @+<< convert-at-root docstring >>
        # @+node:ekr.20210309035627.1: *4* << convert-at-root docstring >>
        # @@wrap
        """
        The convert-at-root command converts @root to @clean throughout the
        outline.

        This command is not perfect. You will need to adjust the outline by hand if
        the command reports errors. I recommend using git diff to ensure that the
        resulting external files are roughly equivalent after running this command.

        This command attempts to do the following:

        - For each node with an @root <path> directive in the body, change the head to
          @clean <path>. The command does *not* change the headline if the node is
          a section definition node. In that case, the command reports an error.

        - Clones and moves nodes as needed so that section definition nodes appear
          as descendants of nodes containing section references. To find section
          definition nodes, the command looks in all @unit trees. After finding the
          required definition node, the command makes a clone of the node and moves
          the clone so it is the last child of the node containing the section
          references. This move may fail. If so, the command reports an error.
        """
        # @-<< convert-at-root docstring >>
        c = event.get('c') if event else None
        if not c:
            return
        ConvertAtRoot().convert_file(c)

    # @+node:ekr.20170806094319.11: *3* efc.clean-at-clean commands
    # @+node:ekr.20170806094319.5: *4* efc.cleanAtCleanFiles
    @cmd('clean-at-clean-files')
    def cleanAtCleanFiles(self, event: LeoKeyEvent | None = None) -> None:
        """Adjust whitespace in all @clean files."""
        c = self.c
        undoType = 'clean-at-clean-files'
        c.undoer.beforeChangeGroup(c.p, undoType, verboseUndoGroup=True)
        total = 0
        for p in c.all_unique_positions():
            if p.isAtCleanNode():
                n = 0
                for p2 in p.subtree():
                    bunch2 = c.undoer.beforeChangeNodeContents(p2)
                    if self.cleanAtCleanNode(p2):
                        n += 1
                        total += 1
                        c.undoer.afterChangeNodeContents(p2, undoType, bunch2)
                g.es_print(f"{n} node{g.plural(n)} {p.h}")
        # Call this only once, at end.
        c.undoer.afterChangeGroup(c.p, undoType)
        if total == 0:
            g.es("Command did not find any whitespace to adjust")
        g.es_print(f"{total} total node{g.plural(total)}")

    # @+node:ekr.20170806094319.8: *4* efc.cleanAtCleanNode
    def cleanAtCleanNode(self, p: Position) -> bool:
        """Adjust whitespace in p, part of an @clean tree."""
        s = p.b.strip()
        if not s or p.h.strip().startswith('<<'):
            return False
        ws = '\n\n' if g.match_word(s, 0, 'class') else '\n'
        s2 = ws + s + ws
        if changed := s2 != p.b:
            p.b = s2
            p.setDirty()
        return changed

    # @+node:ekr.20170806094319.10: *4* efc.cleanAtCleanTree
    @cmd('clean-at-clean-tree')
    def cleanAtCleanTree(self, event: LeoKeyEvent | None = None) -> None:
        """
        Clean whitespace in the nearest @clean tree.

        This command is not undoable.
        """
        c = self.c
        # Look for an @clean node.
        for p in c.p.self_and_parents(copy=False):
            if p.isAtCleanNode():
                break
        else:
            g.es_print('no @clean node found', p.h, color='blue')
            return
        n = 0
        for p2 in p.subtree():
            if self.cleanAtCleanNode(p2):
                n += 1
        if n > 0:
            c.setChanged()
            c.undoer.clearAndWarn('clean-at-clean-tree')
        g.es_print(f"{n} node{g.plural(n)} cleaned")

    # @+node:ekr.20170806094317.6: *3* efc.compareAnyTwoFiles & helpers
    @cmd('file-compare-two-leo-files')
    @cmd('compare-two-leo-files')
    def compareAnyTwoFiles(self, event: LeoKeyEvent | None = None) -> None:
        """Compare two files."""
        c = c1 = self.c
        w = c.frame.body.wrapper
        commanders = g.app.commanders()
        if g.app.diff:
            if len(commanders) == 2:
                c1, c2 = commanders
                fn1 = c1.shortFileName()
                fn2 = c2.shortFileName()
                g.es('--diff auto compare', color='red')
                g.es(fn1)
                g.es(fn2)
            else:
                g.es('expecting two .leo files')
                return
        else:
            # Prompt for the file to be compared with the present outline.
            filetypes = [
                ("Leo files", "*.leo *.leojs *.db"),
                ("All files", "*"),
            ]
            fileName = g.app.gui.runOpenFileDialog(
                c, title="Compare Leo Files", filetypes=filetypes
            )
            if not fileName:
                return
            # Read the file into the hidden commander.
            c2 = g.createHiddenCommander(fileName)  # type:ignore # The next line tests c2.
            if not c2:
                return
        # Compute the inserted, deleted and changed dicts.
        d1 = self.createFileDict(c1)
        d2 = self.createFileDict(c2)
        inserted, deleted, changed = self.computeChangeDicts(d1, d2)
        # Create clones of all inserted, deleted and changed dicts.
        self.createAllCompareClones(c1, c2, inserted, deleted, changed)
        # Fix bug 1231656: File-Compare-Leo-Files leaves other file open-count incremented.
        if not g.app.diff:
            g.app.forgetOpenFile(fn=c2.fileName())
            c2.frame.destroySelf()
            g.app.gui.set_focus(c, w)
        # The inserted, deleted and changed dicts nodes may come from c2, a different Commander.
        c.recreateGnxDict()  # So update c.fileCommands.gnxDict.

    # @+node:ekr.20170806094317.9: *4* efc.computeChangeDicts
    def computeChangeDicts(self, d1: dict, d2: dict) -> tuple[dict, dict, dict]:
        """
        Compute inserted, deleted, changed dictionaries.

        New in Leo 4.11: show the nodes in the *invisible* file, d2, if possible.
        """
        inserted = {}
        for key in d2:
            if not d1.get(key):
                inserted[key] = d2.get(key)
        deleted = {}
        for key in d1:
            if not d2.get(key):
                deleted[key] = d1.get(key)
        changed = {}
        for key in d1:
            if d2.get(key):
                p1 = d1.get(key)
                p2 = d2.get(key)
                if p1 and p2 and p1.h != p2.h or p1.b != p2.b:  # type:ignore
                    changed[key] = p2  # Show the node in the *other* file.
        return inserted, deleted, changed

    # @+node:ekr.20170806094317.11: *4* efc.createAllCompareClones & helper
    def createAllCompareClones(
        self,
        c1: Cmdr,
        c2: Cmdr,
        inserted: dict,
        deleted: dict,
        changed: dict,
    ) -> None:
        """Create the comparison trees."""
        c = self.c  # Always use the visible commander
        assert c == c1
        # Create parent node at the start of the outline.
        u, undoType = c.undoer, 'Compare Two Files'
        u.beforeChangeGroup(c.p, undoType)
        undoData = u.beforeInsertNode(c.p)
        parent = c.p.insertAfter()
        parent.setHeadString(undoType)
        u.afterInsertNode(parent, undoType, undoData)
        fn1 = c1.shortFileName()
        fn2 = c2.shortFileName()
        for d, kind in (
            (deleted, f"not in {fn2}"),
            (inserted, f"not in {fn1}"),
            (changed, f"changed: as in {fn2}"),
        ):
            self.createCompareClones(d, kind, parent)
        c.selectPosition(parent)
        u.afterChangeGroup(parent, undoType)
        c.redraw()

    # @+node:ekr.20170806094317.12: *5* efc.createCompareClones
    def createCompareClones(self, d: dict[str, Position], kind: str, parent: Position) -> None:
        if d:
            c = self.c  # Use the visible commander.
            parent = parent.insertAsLastChild()
            parent.setHeadString(kind)
            for key in d:
                p = d.get(key)
                if not p:
                    continue  # PR #4812
                if not kind.endswith(('.leo', '.leojs')) and p.isAnyAtFileNode():
                    # Don't make clones of @<file> nodes for wrapped files.
                    pass
                elif p.v.context == c:
                    clone = p.clone()
                    clone.moveToLastChildOf(parent)
                else:
                    # Fix bug 1160660: File-Compare-Leo-Files creates "other file" clones.
                    copy = p.copyTreeAfter()
                    copy.moveToLastChildOf(parent)
                    for p2 in copy.self_and_subtree(copy=False):
                        p2.v.context = c

    # @+node:ekr.20170806094317.17: *4* efc.createFileDict
    def createFileDict(self, c: Cmdr) -> dict[str, Position]:
        """Create a dictionary of all relevant positions in commander c."""
        d: dict[str, Position] = {}
        for p in c.all_positions():
            d[p.v.fileIndex] = p.copy()
        return d

    # @+node:ekr.20170806094317.19: *4* efc.dumpCompareNodes
    def dumpCompareNodes(
        self,
        fileName1: str,
        fileName2: str,
        inserted: dict,
        deleted: dict,
        changed: dict,
    ) -> None:
        for d, kind in (
            (inserted, f"inserted (only in {fileName1})"),
            (deleted, f"deleted  (only in {fileName2})"),
            (changed, 'changed'),
        ):
            g.pr('\n', kind)
            for key, p in d.items():
                g.pr(f"{key:>32} {p.h}")

    # @+node:ekr.20170806094319.3: *3* efc.compareTrees
    def compareTrees(self, p1: Position, p2: Position, tag: str) -> None:
        class CompareTreesController:
            # @+others
            # @+node:ekr.20170806094318.18: *4* ct.compare
            def compare(self, d1: dict, d2: dict, root: Position) -> Position:
                """Compare dicts d1 and d2."""
                for h in sorted(d1.keys()):
                    p1, p2 = d1.get(h), d2.get(h)
                    if not p1 or not p2:
                        continue  # PR #4812
                    if h in d2:
                        lines1, lines2 = g.splitLines(p1.b), g.splitLines(p2.b)
                        if aList := list(difflib.unified_diff(lines1, lines2, 'vr1', 'vr2')):
                            p = root.insertAsLastChild()
                            p.h = h
                            p.b = ''.join(aList)
                            p1.clone().moveToLastChildOf(p)
                            p2.clone().moveToLastChildOf(p)
                    elif p1.b.strip():
                        # Only in p1 tree, and not an organizer node.
                        p = root.insertAsLastChild()
                        p.h = h + f"({p1.h} only)"
                        p1.clone().moveToLastChildOf(p)
                for h in sorted(d2.keys()):
                    p2 = d2.get(h)
                    if p2 and h not in d1 and p2.b.strip():
                        # Only in p2 tree, and not an organizer node.
                        p = root.insertAsLastChild()
                        p.h = h + f"({p2.h} only)"
                        p2.clone().moveToLastChildOf(p)
                return root

            # @+node:ekr.20170806094318.19: *4* ct.run
            def run(self, c: Cmdr, p1: Position, p2: Position, tag: str) -> None:
                """Main line."""
                self.c = c
                root = c.p.insertAfter()
                root.h = tag
                d1 = self.scan(p1)
                d2 = self.scan(p2)
                self.compare(d1, d2, root)
                c.p.contract()
                root.expand()
                c.selectPosition(root)
                c.redraw()

            # @+node:ekr.20170806094319.2: *4* ct.scan
            def scan(self, p1: Position) -> dict[str, Position]:
                """
                Create a dict of the methods in p1.
                Keys are headlines, stripped of prefixes.
                Values are copies of positions.
                """
                d: dict[str, Position] = {}
                for p in p1.self_and_subtree(copy=False):
                    h = p.h.strip()
                    i = h.find('.')
                    if i > -1:
                        h = h[i + 1 :].strip()
                    if h in d:
                        g.es_print('duplicate', p.h)
                    else:
                        d[h] = p.copy()
                return d

            # @-others

        CompareTreesController().run(self.c, p1, p2, tag)

    # @+node:ekr.20170806094318.1: *3* efc.deleteFile
    @cmd('file-delete')
    def deleteFile(self, event: LeoKeyEvent | None = None) -> None:
        """Prompt for the name of a file and delete it."""
        k = self.c.k
        k.setLabelBlue('Delete File: ')
        k.extendLabel(os.getcwd() + os.sep)
        k.get1Arg(event, handler=self.deleteFile1)

    def deleteFile1(self, event: LeoKeyEvent | None = None) -> None:
        k = self.c.k
        k.keyboardQuit()
        k.clearState()
        try:
            os.remove(k.arg)
            k.setStatusLabel(f"Deleted: {k.arg}")
        except Exception:
            k.setStatusLabel(f"Not Deleted: {k.arg}")

    # @+node:ekr.20170806094318.3: *3* efc.diff (file-diff-files)
    @cmd('file-diff-files')
    def diff(self, event: LeoKeyEvent | None = None) -> None:
        """Creates a node and puts the diff between 2 files into it."""
        c, u = self.c, self.c.undoer
        fn = self.getReadableTextFile()
        if not fn:
            return
        fn2 = self.getReadableTextFile()
        if not fn2:
            return
        s1, e = g.readFileIntoString(fn)
        if s1 is None:
            return
        s2, e = g.readFileIntoString(fn2)
        if s2 is None:
            return
        lines1, lines2 = g.splitLines(s1), g.splitLines(s2)
        aList = difflib.ndiff(lines1, lines2)
        # add as last top level like other 'diff' result nodes
        c.selectPosition(c.lastTopLevel())  # pre-select to help undo-insert
        undoData = u.beforeInsertNode(c.p)  # c.p is subject of 'insertAfter'
        p = c.p.insertAfter()
        p.h = 'diff'
        p.b = ''.join(aList)
        u.afterInsertNode(p, 'file-diff-files', undoData)
        c.redraw()

    # @+node:ekr.20170806094318.6: *3* efc.getReadableTextFile
    def getReadableTextFile(self) -> str:
        """Prompt for a text file."""
        c = self.c
        fn = g.app.gui.runOpenFileDialog(
            c,
            title='Open Text File',
            filetypes=[
                ("Text", "*.txt"),
                ("All files", "*"),
            ],
        )
        return fn

    # @+node:ekr.20170819035801.90: *3* efc.gitDiff (gd & git-diff)
    @cmd('git-diff')
    @cmd('gd')
    def gitDiff(self, event: LeoKeyEvent | None = None) -> None:
        """Produce a Leonine git diff."""
        GitDiffController(c=self.c).git_diff(rev1='HEAD')

    # @+node:ekr.20201215093414.1: *3* efc.gitDiffPR (git-diff-pr & git-diff-pull-request)
    @cmd('git-diff-pull-request')
    @cmd('git-diff-pr')
    def gitDiffPullRequest(self, event: LeoKeyEvent | None = None) -> None:
        """
        Produce a Leonine diff of pull request in the current branch.
        """
        GitDiffController(c=self.c).diff_pull_request()

    # @+node:ekr.20260707054655.1: *3* efc.cloneDiffPR (clone-diff-pr)
    @cmd('clone-diff-pr')
    def cloneDiffPR(self, event: LeoKeyEvent | None = None) -> None:
        """
        Show the added, deleted and changed nodes (without diffs) of the given PR.
        """
        GitDiffController(c=self.c).clone_diff_pr()

    # @+node:ekr.20170806094318.7: *3* efc.insertFile
    @cmd('file-insert')
    def insertFile(self, event: LeoKeyEvent | None = None) -> None:
        """
        Prompt for the name of a file.
        Insert the file's contents in the body at the insertion point.
        """
        w = event.w if event else None
        if not w:
            return  # PR #4812
        if not g.isTextWrapper(w):
            return
        fn = self.getReadableTextFile()
        if not fn:
            return
        s, e = g.readFileIntoString(fn)
        if s is not None:
            self.beginCommand(w, undoType='insert-file')
            i = w.getInsertPoint()
            w.insert(i, s)
            w.seeInsertPoint()
            self.endCommand(changed=True, setLabel=True)

    # @+node:ekr.20170806094318.9: *3* efc.makeDirectory
    @cmd('directory-make')
    def makeDirectory(self, event: LeoKeyEvent | None = None) -> None:
        """Prompt for the name of a directory and create it."""
        k = self.c.k
        k.setLabelBlue('Make Directory: ')
        k.extendLabel(os.getcwd() + os.sep)
        k.get1Arg(event, handler=self.makeDirectory1)

    def makeDirectory1(self, event: LeoKeyEvent | None = None) -> None:
        k = self.c.k
        k.keyboardQuit()
        k.clearState()
        try:
            os.mkdir(k.arg)
            k.setStatusLabel(f"Created: {k.arg}")
        except Exception:
            k.setStatusLabel(f"Not Created: {k.arg}")

    # @+node:ekr.20170806094318.14: *3* efc.removeDirectory
    @cmd('directory-remove')
    def removeDirectory(self, event: LeoKeyEvent | None = None) -> None:
        """Prompt for the name of a directory and delete it."""
        k = self.c.k
        k.setLabelBlue('Remove Directory: ')
        k.extendLabel(os.getcwd() + os.sep)
        k.get1Arg(event, handler=self.removeDirectory1)

    def removeDirectory1(self, event: LeoKeyEvent | None = None) -> None:
        k = self.c.k
        k.keyboardQuit()
        k.clearState()
        try:
            os.rmdir(k.arg)
            k.setStatusLabel(f"Removed: {k.arg}")
        except Exception:
            k.setStatusLabel(f"Not Removed: {k.arg}")

    # @+node:ekr.20170806094318.15: *3* efc.saveFile (save-file-by-name)
    @cmd('file-save-by-name')
    @cmd('save-file-by-name')
    def saveFile(self, event: LeoKeyEvent | None = None) -> None:
        """Prompt for the name of a file and put the body text of the selected node into it.."""
        c = self.c
        w = event.w if event else None
        if not w:
            return
        if not g.isTextWrapper(w):
            return
        fileName = g.app.gui.runSaveFileDialog(
            c,
            title='save-file',
            filetypes=[
                ("Text", "*.txt"),
                ("All files", "*"),
            ],
        )
        if fileName:
            try:
                s = w.getAllText()
                with open(fileName, 'w') as f:
                    f.write(s)
            except OSError:
                g.es('can not create', fileName)

    # @+node:ekr.20170806094319.15: *3* efc.toggleAtAutoAtEdit & helpers
    @cmd('toggle-at-auto-at-edit')
    def toggleAtAutoAtEdit(self, event: LeoKeyEvent | None = None) -> None:
        """Toggle between @auto and @edit, preserving insert point, etc."""
        p = self.c.p
        if p.isAtEditNode():
            self.toAtAuto(p)
            return
        for p in p.self_and_parents():
            if p.isAtAutoNode():
                self.toAtEdit(p)
                return
        g.es_print('Not in an @auto or @edit tree.', color='blue')

    # @+node:ekr.20170806094319.17: *4* efc.toAtAuto
    def toAtAuto(self, p: Position) -> None:
        """Convert p from @edit to @auto."""
        c = self.c
        # Change the headline.
        p.h = '@auto' + p.h[5:]
        c.refreshFromDisk(p)
        c.bodyWantsFocus()

    # @+node:ekr.20170806094319.19: *4* efc.toAtEdit
    def toAtEdit(self, p: Position) -> None:
        """Convert p from @auto to @edit."""
        c = self.c
        p.h = '@edit' + p.h[5:]
        c.refreshFromDisk(p)
        c.bodyWantsFocus()

    # @-others


# @+node:ekr.20170806094320.13: ** class GitDiffController
class GitDiffController:
    """A class to do git diffs."""

    def __init__(self, c: Cmdr) -> None:
        self.c = c
        self.diff_leo_files = True
        self.file_node: Position
        self.root: Position
        self.reloadSettings()

    def reloadSettings(self) -> None:
        config = self.c.config
        self.diff_leo_files = config.getBool('diff-leo-files', default=True)

    # @+others
    # @+node:ekr.20180510095544.1: *3* gdc.Entries...
    # @+node:ekr.20260707060104.1: *4* gdc.clone_diff_file
    def clone_diff_file(self, fn: str, rev1: str = 'HEAD', rev2: str = '') -> None:
        """
        Create an outline for the git-diff-pr-clone-changed command.
        """
        c = self.c
        directory = self.get_parent_of_git_directory()
        if not directory:
            return
        # #4095: honor @bool diff-leo-files.
        if fn.endswith(('.leo', '.leojs')) and not self.diff_leo_files:
            return
        s1 = self.get_file_from_rev(rev1, fn)
        s2 = self.get_file_from_rev(rev2, fn)

        self.file_node = self.root.insertAsLastChild()
        self.file_node.h = fn.strip()

        # #1777: The file node will contain the entire added/deleted file.
        if not s1:
            self.file_node.h = f"Added: {self.file_node.h}"
            return
        if not s2:
            self.file_node.h = f"Deleted: {self.file_node.h}"
            return

        # Finish.
        path = g.finalize_join(directory, fn)  # #1781: bug fix.
        c1 = c2 = None
        if fn.endswith(('.leo', '.leojs')):
            c1 = self.make_leo_outline(fn, path, s1, rev1)
            c2 = self.make_leo_outline(fn, path, s2, rev2)
        else:
            root = self.find_file(fn)
            if c.looksLikeDerivedFile(path):
                c1 = self.make_at_file_outline(fn, s1, rev1)
                c2 = self.make_at_file_outline(fn, s2, rev2)
            elif root:
                c1 = self.make_at_clean_outline(fn, root, s1, rev1)
                c2 = self.make_at_clean_outline(fn, root, s2, rev2)
        if c1 and c2:
            self.make_clone_diff_outlines(c1, c2, fn, rev1, rev2)
            self.file_node.b = f"@language {c2.target_language}\n"

    # @+node:ekr.20170806094320.6: *4* gdc.diff_file
    def diff_file(self, fn: str, rev1: str = 'HEAD', rev2: str = '') -> None:
        """
        Create an outline describing the git diffs for fn.
        """
        c = self.c
        directory = self.get_parent_of_git_directory()
        if not directory:
            return
        # #4095: honor @bool diff-leo-files.
        if fn.endswith(('.leo', '.leojs')) and not self.diff_leo_files:
            return
        s1 = self.get_file_from_rev(rev1, fn)
        s2 = self.get_file_from_rev(rev2, fn)
        lines1 = g.splitLines(s1)
        lines2 = g.splitLines(s2)
        diff_list = list(
            difflib.unified_diff(
                lines1,
                lines2,
                rev1 or 'uncommitted',
                rev2 or 'uncommitted',
            )
        )
        diff_list.insert(0, '@ignore\n@nosearch\n@language patch\n')
        self.file_node = self.create_file_node(diff_list, fn)
        # #1777: The file node will contain the entire added/deleted file.
        if not s1:
            self.file_node.h = f"Added: {self.file_node.h}"
            return
        if not s2:
            self.file_node.h = f"Deleted: {self.file_node.h}"
            return
        # Finish.
        path = g.finalize_join(directory, fn)  # #1781: bug fix.
        c1 = c2 = None
        if fn.endswith(('.leo', '.leojs')):
            c1 = self.make_leo_outline(fn, path, s1, rev1)
            c2 = self.make_leo_outline(fn, path, s2, rev2)
        else:
            root = self.find_file(fn)
            if c.looksLikeDerivedFile(path):
                c1 = self.make_at_file_outline(fn, s1, rev1)
                c2 = self.make_at_file_outline(fn, s2, rev2)
            elif root:
                c1 = self.make_at_clean_outline(fn, root, s1, rev1)
                c2 = self.make_at_clean_outline(fn, root, s2, rev2)
        if c1 and c2:
            self.make_diff_outlines(c1, c2, fn, rev1, rev2)
            self.file_node.b = f"{self.file_node.b.rstrip()}\n@language {c2.target_language}\n"

    # @+node:ekr.20260707054941.1: *4* gdc.clone_diff_pr
    def clone_diff_pr(self) -> None:
        """
        Like git-diff-pr, showing only clones of changed nodes, organized by file:
        - Omit the diffs.
        - Don't show added or deleted nodes.
        """
        directory = self.get_parent_of_git_directory()
        if not directory:
            return
        if aList := g.execGitCommand("git rev-parse devel", directory):
            devel_rev = aList[0]
            devel_rev = devel_rev[:8]
            g.trace('devel_rev', devel_rev)
            self.clone_diff_two_revs(
                rev1=devel_rev,  # Before: Latest devel commit.
                rev2='HEAD',  # After: Latest branch commit
            )
        else:
            g.es_print('FAIL: git rev-parse devel')

    # @+node:ekr.20201208115447.1: *4* gdc.diff_pull_request
    def diff_pull_request(self) -> None:
        """
        Create a Leonine version of the diffs that would be
        produced by a pull request between two branches.
        """
        directory = self.get_parent_of_git_directory()
        if not directory:
            return
        if aList := g.execGitCommand("git rev-parse devel", directory):
            devel_rev = aList[0]
            devel_rev = devel_rev[:8]
            g.trace('devel_rev', devel_rev)
            self.diff_two_revs(
                rev1=devel_rev,  # Before: Latest devel commit.
                rev2='HEAD',  # After: Latest branch commit
            )
        else:
            g.es_print('FAIL: git rev-parse devel')

    # @+node:ekr.20180506064102.10: *4* gdc.diff_two_branches
    def diff_two_branches(self, branch1: str, branch2: str, fn: str) -> None:
        """Create an outline describing the git diffs for fn."""
        c = self.c
        u, undoType = c.undoer, 'diff-two-branches'
        if not self.get_parent_of_git_directory():
            return
        c.selectPosition(c.lastTopLevel())
        undoData = u.beforeInsertNode(c.p)
        self.root = p = c.lastTopLevel().insertAfter()
        p.h = f"git-diff-branches {branch1} {branch2}"
        s1 = self.get_file_from_branch(branch1, fn)
        s2 = self.get_file_from_branch(branch2, fn)
        lines1 = g.splitLines(s1)
        lines2 = g.splitLines(s2)
        diff_list = list(
            difflib.unified_diff(
                lines1,
                lines2,
                branch1,
                branch2,
            )
        )
        diff_list.insert(0, '@ignore\n@nosearch\n@language patch\n')
        self.file_node = self.create_file_node(diff_list, fn)
        c1: Cmdr | None
        c2: Cmdr | None
        if c.looksLikeDerivedFile(fn):
            c1 = self.make_at_file_outline(fn, s1, branch1)
            c2 = self.make_at_file_outline(fn, s2, branch2)
        else:
            if root := self.find_file(fn):
                c1 = self.make_at_clean_outline(fn, root, s1, branch1)
                c2 = self.make_at_clean_outline(fn, root, s2, branch2)
            else:
                c1 = c2 = None
        if c1 and c2:
            self.make_diff_outlines(c1, c2, fn, rev1=branch1, rev2=branch2)
            self.file_node.b = f"{self.file_node.b.rstrip()}\n@language {c2.target_language}\n"
        u.afterInsertNode(self.root, undoType, undoData)
        self.finish()

    # @+node:ekr.20180507212821.1: *4* gdc.diff_two_revs
    def diff_two_revs(self, rev1: str = 'HEAD', rev2: str = '', path: str | None = None) -> None:
        """
        Create an outline describing the git diffs for all files changed
        between rev1 and rev2.
        """
        c, u = self.c, self.c.undoer

        if not self.get_parent_of_git_directory():
            return
        # Get list of changed files.
        files = self.get_files(rev1, rev2, path=path)
        n = len(files)
        if not g.unitTesting:
            g.es_print(f"diffing {n} file{g.plural(n)}")
            if n > 5:
                g.es_print('This may take a while...')
        c.selectPosition(c.lastTopLevel())  # pre-select to help undo-insert

        # Create the root node.
        undoData = u.beforeInsertNode(c.p)  # c.p is subject of 'insertAfter'
        self.root = c.lastTopLevel().insertAfter()
        root_h = f"git diff revs: {rev1} {rev2}"
        if path and path.strip():
            root_h += f" ({path})"
        self.root.h = root_h
        self.root.b = '@ignore\n@nosearch\n'

        # Create diffs of all files.
        for i, fn in enumerate(files):
            self.diff_file(fn=fn, rev1=rev1, rev2=rev2)
            if (i % 10) == 0:
                g.es_print(f"diff: {i} files", color='blue')

        u.afterInsertNode(self.root, 'diff-two-revs', undoData)
        self.finish()
        g.es_print(f"done! diffed: {n} files")

    # @+node:ekr.20170806094320.12: *4* gdc.git_diff & helper
    def git_diff(self, rev1: str = 'HEAD', rev2: str = '') -> None:
        """The main line of the git diff command."""
        if not self.get_parent_of_git_directory():
            return
        # Diff the given revs.
        if self.diff_revs(rev1, rev2):
            return
        # Go back at most 5 revs...
        n1, n2 = 1, 0
        while n1 <= 5:
            ok = self.diff_revs(
                rev1=f"HEAD@{{{n1}}}",
                rev2=f"HEAD@{{{n2}}}",
            )
            if ok:
                return
            n1, n2 = n1 + 1, n2 + 1
        if not ok:
            g.es_print('no changed readable files from HEAD@{1}..HEAD@{5}')

    # @+node:ekr.20170820082125.1: *5* gdc.diff_revs
    def diff_revs(self, rev1: str, rev2: str) -> bool:
        """
        A helper for Leo's git-diff command

        Diff all files given by rev1 and rev2.
        """
        c = self.c
        u = c.undoer
        undoType = 'git-diff-revs'
        files = self.get_files(rev1, rev2)
        if not files:
            return False
        c.selectPosition(c.lastTopLevel())
        undoData = u.beforeInsertNode(c.p)
        self.root = self.create_root(rev1, rev2)
        for fn in files:
            self.diff_file(fn=fn, rev1=rev1, rev2=rev2)
        u.afterInsertNode(self.root, undoType, undoData)
        self.finish()
        return True

    # @+node:ekr.20230705082614.1: *4* gdc.node_history & helpers
    def node_history(self, path: str, gnxs: list[str], limit: int | None = None) -> None:
        """Produce a Leonine history of the node whose file name and gnx are given."""
        c = self.c
        # The path must be absolute.
        if not os.path.isabs(path):
            g.trace(f"Not absolute: {path!r}")
            return
        # Get all the revs.
        rev_list = self._get_revs_for_path(path)
        # Get the files.
        contents_list = self._get_contents_for_revs(path, rev_list, limit)
        # Truncate revs_list so it has the same length as contents_list.
        truncated_revs_list = rev_list[: len(contents_list)]
        # Find changed nodes.
        diff_list = self._get_diff_list(contents_list, gnxs, path, truncated_revs_list)
        if not diff_list:
            return
        # Create the root node.
        self.root = p = c.lastTopLevel().insertAfter()
        limit_s = 'None' if limit is None else limit
        p.h = f"node_history: {g.shortFileName(path)}"
        p.b = f"@ignore\n@nosearch\n\n# gnxs: {', '.join(gnxs)}\n# limit: {limit_s}"
        # Generate all other nodes.
        self._generate_nodes(diff_list, truncated_revs_list)
        self.finish()

    # @+node:ekr.20230719161306.1: *5* gdc._get_action
    def _get_action(
        self,
        i: int,  # The index into contents_list and revs_list
        path: str,
        contents_list: list[list[str]],  # Lines for each contents.
        # Patterns matching @+node sentinels for each gnx.
        node_patterns: list[tuple[str, re.Pattern]],
        revs_list: list[str],
    ) -> g.Bunch | None:
        """
        Return a g.Bunch describing the action to be taken at rev i.
        """
        assert len(contents_list) == len(revs_list)
        if i + 1 >= len(revs_list):
            return None  # Can't diff past this rev.

        # Step 1: Find all nodes matching one of the gnx's in contents[i], contents[i+1]
        nodes: list[list[tuple]] = [
            [],  # Tuples describing nodes in rev i.
            [],  # Tuples describing nodes in rev i + 1
        ]
        for index in (0, 1):
            rev_i = i + index
            for gnx, pattern in node_patterns:
                if node_info := self._find_node(
                    contents_list[rev_i], pattern, gnx, revs_list[rev_i]
                ):
                    nodes[index].append((rev_i, gnx, node_info))

        # Quick test: are both sets of lines the same?
        if not nodes[0] and not nodes[1]:
            skip_flag = True
        elif nodes[0] and nodes[1]:
            range0 = nodes[0][0][2]
            range1 = nodes[1][0][2]
            contents0 = contents_list[i]
            contents1 = contents_list[i + 1]
            body0 = contents0[range0[0] : range0[1]]
            body1 = contents1[range1[0] : range1[1]]
            skip_flag = body0 == body1
        else:
            skip_flag = False

        # Return if there is nothing to diff.
        if skip_flag:
            return None

        # Step 2: Create the g.Bunch
        kind = 'add' if not nodes[0] else 'delete' if not nodes[1] else 'diff'
        nodes0, nodes1 = nodes[0], nodes[1]
        if nodes0:
            gnx0 = nodes0[0][1]
            range0 = nodes0[0][2]
            contents0 = contents_list[i]
            body0 = contents0[range0[0] : range0[1]]
        else:
            gnx0 = range0 = contents0 = body0 = None  # type:ignore
        if nodes1:
            gnx1 = nodes1[0][1]
            range1 = nodes1[0][2]
            contents1 = contents_list[i + 1]
            body1 = contents1[range1[0] : range1[1]]
        else:
            gnx1 = range1 = contents1 = body1 = None  # type:ignore
        return g.Bunch(
            i=i,
            kind=kind,
            gnx0=gnx0,
            gnx1=gnx1,
            rev0=revs_list[i],
            rev1=revs_list[i + 1],
            body0=body0,
            body1=body1,
            # Optional debugging info.
            nodes0=nodes0,
            contents0=contents0,
            range0=range0,
            nodes1=nodes1,
            contents1=contents1,
            range1=range1,
        )

    # @+node:ekr.20230719170046.1: *5* gdc._find_node
    node_ending_patterns = (
        re.compile(r'^\s*#@\+node:(.*?):'),  # A start node sentinel.
        re.compile(r'^\s*#@\-others'),  # A -others sentinel
        re.compile(r'^\s*#@\-leo'),  # A -leo sentinel.
    )

    def _find_node(
        self,
        contents: list[str],  # The list of lines.
        pattern: re.Pattern,
        # For debugging only.
        gnx: str,  # gnx being matched.
        rev: str,  # Full hash.
    ) -> tuple[int, int] | None:
        """
        Return (i1, i2) the range of lines of the node, or None.
        i1: The index of the line matching pattern.
        i2: The index of the line ending the node.


        The lines start at the first line matching the pattern.

        The lines end at the first line matching one of the ending patterns.
        """
        for i, line in enumerate(contents):
            if pattern.match(line):
                break
        else:
            return None
        i1 = i
        i += 1
        while i < len(contents):
            line = contents[i]
            i += 1
            if any(z.match(line) for z in self.node_ending_patterns):
                # g.printObj(contents[i1:i], tag=f"Found {rev[:7]} {gnx} {i1}:{i}")
                return (i1, i - 1)
        return None

    # @+node:ekr.20230720085415.1: *5* gdc._generate_nodes
    def _generate_nodes(self, diff_list: list[g.Bunch], revs_list: list[str]) -> None:
        """
        Generate all diff nodes from diff_list, a list of g.Bunches returned from _get_action.
        """
        # self._trace_diff_list(diff_list)
        for b in diff_list:
            # self._trace_kind(b, revs_list)
            p = self.root.insertAsLastChild()
            gnx0_s = b.gnx0 or ''
            gnx1_s = b.gnx1 or ''
            gnxs_s = gnx0_s if b.gnx0 == b.gnx1 else f"{gnx0_s} {gnx1_s}"
            p.h = f"{b.i:>4} {b.kind}"  # {gnx0_s} {gnx1_s}"
            if b.kind == 'diff':
                diff = list(difflib.unified_diff(b.body0 or [], b.body1 or [], b.rev0, b.rev1))
                p.b = f"diff {gnxs_s}\n\n{''.join(diff)}"
                child1 = p.insertAsLastChild()
                child1.h = 'old'
                child1.b = ''.join(b.body0 or [])
                child2 = p.insertAsLastChild()
                child2.h = 'new'
                child2.b = ''.join(b.body1 or [])
            elif b.kind == 'add':
                p.b = f"add {gnxs_s}\n\n{''.join(b.body1 or [])}"
            elif b.kind == 'delete':
                p.b = f"delete {gnxs_s}\n\n{''.join(b.body0 or [])}"
            else:
                g.trace(f"Bad b.kind: {b.kind!r}")

    # @+node:ekr.20230705085430.1: *5* gdc._get_contents_for_revs
    def _get_contents_for_revs(
        self,
        path: str,
        rev_list: list[str],
        limit: int | None = None,
    ) -> list[list[str]]:
        """
        Return the contents of the file as a list of lines.
        path: the full path to the file.
        rev_list: the list of full git hashes for each rev.
        limit: None (no limit), the number revs to search.
        """
        # Note: the time module excludes sleep time. It's inaccurate here.

        # Run the command itself in the leo-editor, the parent of the .git directory.
        git_parent_directory = self.get_parent_of_git_directory()

        # Compute the path relative to the parent.
        relative_path = os.path.relpath(path, git_parent_directory).replace('\\', '/')

        # Get full file contents of rev.
        result: list[list[str]] = []
        of_s = '' if limit is None else f" of {len(rev_list)}"
        n = len(rev_list) if limit is None else min(limit, len(rev_list))
        g.es_print(f"Reading {n}{of_s} revs!\nThis will take a few minutes.")
        for i, rev in enumerate(rev_list[:limit]):
            command = rf"git show {rev}:{relative_path}"
            if aList := g.execGitCommand(command, git_parent_directory):
                result.append(aList)
            if i > 0 and (i % 100) == 0:
                g.es_print(f"Progress: {i} revs")
        g.es_print(f"Done! {n}{of_s} revs")
        return result

    # @+node:ekr.20230719122859.1: *5* gdc._get_diff_list
    def _get_diff_list(
        self,
        contents_list: list[list[str]],  # List of lines for each file.
        gnxs: list[str],
        path: str,
        revs_list: list[str],
    ) -> list[g.Bunch]:
        """Return a list of Bunches describing the nodes to be diffed."""

        assert len(contents_list) == len(revs_list)

        # Compile the patterns once.
        node_patterns = [(gnx, re.compile(rf'^\s*#@\+node:({gnx}):')) for gnx in gnxs]

        # Create the list of g.Bunches.
        node_data_list = []
        for i in range(len(contents_list)):
            if bunch := self._get_action(i, path, contents_list, node_patterns, revs_list):
                node_data_list.append(bunch)
        return node_data_list

    # @+node:ekr.20230705084709.1: *5* gdc._get_revs_for_path
    def _get_revs_for_path(self, path: str) -> list[str]:
        """
        Return the list of full hashes for all commits to the given absolute path.
        """
        # Run the command itself in leo-editor, the parent of the .git directory.
        git_parent_directory = self.get_parent_of_git_directory()

        # Human readable summary.
        # %h (%an %cs %s): Abbreviated hash, author, date, commit message
        args_s = "--no-patch --pretty='format:%H'"  # Just the long hash.
        command = rf"git log {args_s} -- {path}"
        aList = g.execGitCommand(command, git_parent_directory)
        result = [z.strip() for z in aList]
        return result

    # @+node:ekr.20230720085122.1: *5* gdc._trace_diff_list
    def _trace_diff_list(self, diff_list: list[g.Bunch]) -> None:
        """Trace the diff_list."""
        if 1:  # Brief.
            for z in diff_list:
                body_n0 = 'None' if z.body0 is None else len(z.body0)
                body_n1 = 'None' if z.body1 is None else len(z.body1)
                print(
                    f"{z.i:>4} {z.kind:>7} {z.rev0[:7]} {z.rev1[:7]} "
                    # f"len(contents0/1: {n0} {n1} range0/1: {z.range0} {z.range1}")
                    f"len(body0/1): {body_n0} {body_n1}"
                )
        elif 0:  # Too verbose.
            g.printObj(diff_list, tag='diff_list')
        else:  # Verbose.
            for bunch in diff_list:
                result = []
                for key in (
                    'i',
                    'kind',
                    'rev0',
                    'rev1',
                    'body0',
                    'body1',
                    'range0',
                    'range1',
                ):
                    val = bunch.get(key)
                    if val is None:
                        continue
                    if key == 'i':
                        pad_s = ' ' * max(0, 4 - len(str(val)))
                        result.append(f"{key}:{pad_s} {val}")
                    elif key == 'kind':
                        result.append(f"{key}: {val:<6}")
                    elif key.startswith(('body', 'contents')):
                        result.append(f"{key}: {len(val):<3}")
                    elif key.startswith('rev'):
                        result.append(f"{key}: {val[:7]}")
                    else:
                        result.append(f"{key}: {val}")
                print(' '.join(result))

    # @+node:ekr.20230720091027.1: *5* gdc._trace_kind
    def _trace_kind(self, b: g.Bunch, revs_list: list[str]) -> None:
        tag = f"{b.i:>4}: {b.rev0[:7]} {b.rev1[:7]}"
        if b.kind == 'add':
            print(f"{tag}:    add len(body1):   {len(b.body1)}")
        elif b.kind == 'delete':
            print(f"{tag}: delete len(body0):   {len(b.body0)}")
        elif b.kind == 'diff':
            # pad_s = ' ' * (9 + len(tag))
            print(f"{tag}:   diff len(body0/2): {len(b.body0)} {len(b.nodes1)}")
        else:
            g.trace('Unknown kind', repr(b.kind))

    # @+node:ekr.20260112115313.1: *4* gdc.summary_diff_two_revs
    def summary_diff_two_revs(
        self, rev1: str = 'HEAD', rev2: str = '', path: str | None = None
    ) -> None:
        """
        Create an outline describing the git diffs for all files changed
        between rev1 and rev2.
        """
        c, u = self.c, self.c.undoer
        if not self.get_parent_of_git_directory():
            return
        # Get list of changed files.  This is fast.
        files = self.get_files(rev1, rev2, path=path)
        n = len(files)
        if not g.unitTesting:
            g.es_print(f"diffing {n} file{g.plural(n)}")
            if n > 5:
                g.es_print('This may take awhile...')
        c.selectPosition(c.lastTopLevel())  # pre-select to help undo-insert

        # Undoably create the root node.
        undoData = u.beforeInsertNode(c.p)  # c.p is subject of 'insertAfter'
        self.root = c.lastTopLevel().insertAfter()
        root_h = f"git summary diff revs: {rev1} {rev2}"
        if path and path.strip():
            root_h += f" ({path})"
        self.root.h = root_h
        self.root.b = '@nosearch\n'
        for i, fn in enumerate(files):
            if fn.endswith('.py'):
                self.summary_diff_python_file(fn=fn, rev1=rev1, rev2=rev2)
            if (i % 10) == 0:
                g.es_print(f"diff: {i} files", color='blue')
        u.afterInsertNode(self.root, 'summary-diff-two-revs', undoData)
        c.selectPosition(self.root)
        self.root.expand()
        c.redraw(self.root)
        print(f"done! diffed: {n} files")

    # @+node:ekr.20260112115520.1: *4* gdc.summary_diff_python_file & helper
    def summary_diff_python_file(self, fn: str, rev1: str = 'HEAD', rev2: str = '') -> None:
        """
        Create an outline summarizing the git diffs for fn, a python file.
        Return True if this method creates a "Changed" node.
        """
        directory = self.get_parent_of_git_directory()
        if not directory:
            return
        if not fn.endswith('.py'):
            return
        s1 = self.get_file_from_rev(rev1, fn)
        s2 = self.get_file_from_rev(rev2, fn)
        lines1 = g.splitLines(s1)
        lines2 = g.splitLines(s2)
        diff_list = list(
            difflib.unified_diff(lines1, lines2, rev1 or 'uncommitted', rev2 or 'uncommitted')
        )
        child = self.create_file_node(diff_list, fn)
        kind = 'Added' if not s1 else 'Deleted' if not s2 else 'Changed'
        child.h = f"{kind}: {fn}"
        result_s = self.remove_cruft(diff_list) if s1 and s2 else ''.join(diff_list)
        child.b = f"@nosearch\n@language python\n{result_s}"

    # @+node:ekr.20260112130340.1: *5* gdc.remove_cruft
    def remove_cruft(self, diff_list: list[str]) -> str:
        """Remove unwanted lines from diff_list and insert separator lines."""
        results1, sep = [], '# ====='

        # Pass 1: Create results1 without separating blank lines.
        for s in diff_list:
            if s.startswith(('---', '+++')):
                continue
            if s.startswith('@@'):
                results1.append(f"\n{sep}\n\n")
                continue
            if s.startswith(('+', '-')):
                tail = s[1:].strip()
                if tail and not tail.startswith(('#@', '# @')):
                    results1.append(s)

        # Pass 2: Insert blank lines.
        results2 = []
        for i, s in enumerate(results1):
            try:
                last = results1[i - 1]
            except IndexError:
                last = ''
            if (
                s.startswith('-') and last.startswith('+') or
                s.startswith('+') and last.startswith('-')
            ):  # fmt: skip
                results2.append('\n')
            results2.append(s)

        # Pass 3: Remove empty separator sections.
        results3 = ''.join(results2).replace('\n\n\n', '\n\n')
        while f"{sep}\n\n{sep}" in results3:
            results3 = results3.replace(f"{sep}\n\n{sep}", sep)
        return results3

    # @+node:ekr.20180510095801.1: *3* gdc.Utils
    # @+node:ekr.20260707062134.1: *4* gdc.create_clone_diff_node
    def create_clone_diff_node(
        self,
        branch: str,
        c1: Cmdr,
        c2: Cmdr,
        d: dict[str, Any],  # Values: tuple[VNode, VNode] for 'changed' kind, otherwise VNode.
        kind: str,
        rev1: str,
        rev2: str,
    ) -> None:
        """Create nodes for clone-diff-pr."""
        if not d:
            return
        branches_match = (
            branch == rev2 or
            rev2 == 'HEAD' or
            rev1 == 'HEAD' and rev2 == ''  # diffing the latest changes.
        )  # fmt: skip

        # A useful hack: don't create an organizer node for changed nodes.
        if kind.lower() == 'changed':
            parent = self.file_node
        else:
            parent = self.file_node.insertAsLastChild()
            parent.h = kind
        for key in d:
            if kind.lower() == 'changed':
                v1, v2 = d.get(key, (None, None))
                # Make a clone, if possible.
                assert v1.fileIndex == v2.fileIndex
                p_in_c = self.find_gnx(self.c, v1.fileIndex)
                if p_in_c and branches_match:  # #4645.
                    p3 = p_in_c.clone()
                    p3.moveToLastChildOf(parent)
                else:
                    p3 = parent.insertAsLastChild()
                    p3.h = 'New:' + v2.h
                    p3.b = v2.b
            elif kind.lower() == 'added':
                # Make a clone, if possible.
                if v := d.get(key):
                    if new_p := self.find_gnx(self.c, v.fileIndex):
                        p = new_p.clone()
                        p.moveToLastChildOf(parent)
                    else:
                        p = parent.insertAsLastChild()
                        p.h = v.h
                        p.b = v.b
            else:
                if v := d.get(key):
                    p = parent.insertAsLastChild()
                    p.h = v.h
                    p.b = v.b

    # @+node:ekr.20170806191942.2: *4* gdc.create_compare_node
    def create_compare_node(
        self,
        branch: str,
        c1: Cmdr,
        c2: Cmdr,
        d: dict[str, tuple[VNode, VNode]],
        kind: str,
        rev1: str,
        rev2: str,
    ) -> None:
        """Create nodes describing the changes."""
        if not d:
            return
        # Clone nodes only if they are in the current branch.
        # This logic isn't always correct, but it will do for now.
        branches_match = (
            branch == rev2 or
            rev2 == 'HEAD' or
            rev1 == 'HEAD' and rev2 == ''  # diffing the latest changes.
        )  # fmt: skip
        # g.trace(f"{branch=} {rev1=} {rev2=} {branches_match=}")
        parent = self.file_node.insertAsLastChild()
        parent.setHeadString(f"diff: {kind}")
        for key in d:
            if kind.lower() == 'changed':
                v1, v2 = d.get(key, (None, None))
                if v1 and v2:
                    # Organizer node: contains diff
                    organizer = parent.insertAsLastChild()
                    organizer.h = f"diff: {v2.h}"
                    p_in_c = self.find_gnx(self.c, v1.fileIndex)
                    body = list(
                        difflib.unified_diff(
                            g.splitLines(v1.b),
                            g.splitLines(v2.b),
                            rev1 or 'uncommitted',
                            rev2 or 'uncommitted',
                        )
                    )
                    if ''.join(body).strip():
                        body.insert(0, '@ignore\n@nosearch\n@language patch\n')
                        # #4095.
                        language = c2.getLanguage(p_in_c) if p_in_c else c2.target_language
                        body.append(f"@language {language}\n")
                    else:
                        body = ['Only headline has changed']
                    organizer.b = ''.join(body)
                    # Node 2: Old node
                    p2 = organizer.insertAsLastChild()
                    p2.h = 'Old:' + v1.h
                    p2.b = v1.b
                    # Node 3: New node
                    assert v1.fileIndex == v2.fileIndex
                    # Make a clone, if possible.
                    if p_in_c and branches_match:  # #4645.
                        p3 = p_in_c.clone()
                        p3.moveToLastChildOf(organizer)
                    else:
                        p3 = organizer.insertAsLastChild()
                        p3.h = 'New:' + v2.h
                        p3.b = v2.b
            elif kind.lower() == 'added':
                if v := d.get(key):
                    new_p = self.find_gnx(self.c, v.fileIndex)
                    if new_p:  # Make a clone, if possible.
                        p = new_p.clone()
                        p.moveToLastChildOf(parent)
                        # #2950: do not change p.b.
                    else:
                        p = parent.insertAsLastChild()
                        p.h = v.h
                        p.b = v.b
            else:
                if v := d.get(key):
                    p = parent.insertAsLastChild()
                    p.h = v.h
                    p.b = v.b

    # @+node:ekr.20170806094321.1: *4* gdc.create_file_node
    def create_file_node(self, diff_list: list[str], fn: str) -> Position:
        """Create an organizer node for the file."""
        p = self.root.insertAsLastChild()
        p.h = 'diff: ' + fn.strip()
        p.b = ''.join(diff_list)
        return p

    # @+node:ekr.20170806094320.18: *4* gdc.create_root
    def create_root(self, rev1: str, rev2: str) -> Position:
        """Create the top-level organizer node describing the git diff."""
        c = self.c
        r1, r2 = rev1 or '', rev2 or ''
        p = c.lastTopLevel().insertAfter()
        p.h = f"git diff {r1} {r2}"
        p.b = '@ignore\n@nosearch\n'
        if r1 and r2:
            p.b += f"{r1}={self.get_revno(r1)}\n{r2}={self.get_revno(r2)}"
        else:
            p.b += f"{r1}={self.get_revno(r1)}"
        return p

    # @+node:ekr.20170806094320.7: *4* gdc.find_file
    def find_file(self, fn: str) -> Position | None:
        """Return the @<file> node matching fn."""
        c = self.c
        fn = g.os_path_basename(fn)
        for p in c.all_unique_positions():
            if p.isAnyAtFileNode():
                fn2 = p.anyAtFileNodeName()
                if fn2.endswith(fn):
                    return p
        return None

    # @+node:ekr.20170806094321.3: *4* gdc.find_git_working_directory
    def find_git_working_directory(self, directory: str) -> str | None:
        """Return the git working directory, starting at directory."""
        while directory:
            if g.os_path_exists(g.finalize_join(directory, '.git')):
                return directory
            path2 = g.finalize_join(directory, '..')
            if path2 == directory:
                break
            directory = path2
        return None

    # @+node:ekr.20170819132219.1: *4* gdc.find_gnx
    def find_gnx(self, c: Cmdr, gnx: str) -> Position | None:
        """Return a position in c having the given gnx."""
        for p in c.all_unique_positions():
            if p.v.fileIndex == gnx:
                return p
        return None

    # @+node:ekr.20170806094321.5: *4* gdc.finish
    def finish(self) -> None:
        """Finish execution of this command."""
        c = self.c
        if c.checkOutlineXML(dump=True):
            # All is well.
            c.selectPosition(self.root)
            self.root.expand()
            c.redraw(self.root)
        else:
            # Writing the outline would create an invalid outline.
            g.es_print('Deleting the diff. It would create an invalid outline!', color='red')
            c.selectPosition(self.root)
            c.deleteOutline()
            last = c.lastTopLevel()
            c.redraw(last)
            # Re-validate.
            if not c.checkOutlineXML(dump=False):
                g.es_print('The outline is *still* invalid!', color='red')
                g.es_print('Do not save the outline!', color='red')
        c.treeWantsFocusNow()

    # @+node:ekr.20210819080657.1: *4* gdc.get_parent_of_git_directory
    def get_parent_of_git_directory(self) -> str:
        """
        #2143.
        Resolve filename to the nearest directory containing a .git directory.
        """
        c = self.c
        filename = c.fileName()
        if not filename:
            print('git-diff: outline has no name')
            return ''
        directory = os.path.dirname(filename)
        if directory and not os.path.isdir(directory):
            directory = os.path.dirname(directory)
        if not directory:
            print(f"git-diff: outline has no directory. filename: {filename!r}")
            return ''
        # Does path/../ref exist?
        base_directory = g.gitHeadPath(directory)
        if not base_directory:
            print(f"git-diff: no .git directory: {directory!r} filename: {filename!r}")
            return ''
        # This should guarantee that the directory contains a .git directory.
        directory = g.finalize_join(base_directory, '..', '..')
        return directory

    # @+node:ekr.20180506064102.11: *4* gdc.get_file_from_branch
    def get_file_from_branch(self, branch: str, fn: str) -> str:
        """Get the file from the head of the given branch."""
        # #2143
        directory = self.get_parent_of_git_directory()
        if not directory:
            return ''
        command = f"git show {branch}:{fn}"
        lines = g.execGitCommand(command, directory)
        s = ''.join(lines)
        return g.toUnicode(s).replace('\r', '')

    # @+node:ekr.20170806094320.15: *4* gdc.get_file_from_rev
    def get_file_from_rev(self, rev: str, fn: str) -> str:
        """Get the file from the given rev, or the working directory if None."""
        directory = self.get_parent_of_git_directory()
        if not directory:
            return ''
        path = g.finalize_join(directory, fn)

        # Find all the files in the rev.
        if rev:
            command = f"git ls-tree -r {rev} --name-only"
            lines = g.execGitCommand(command, directory)
            if not any(fn in z for z in lines):
                # g.trace(f"{fn} not in {rev}")
                return ''
            # Get the file using git.
            # Use the file name, not the path.
            command = f"git show {rev}:{fn}"
            lines = g.execGitCommand(command, directory)
            return g.toUnicode(''.join(lines)).replace('\r', '')

        # Read the file.
        if not os.path.exists(path):
            # This is not an error. The user has inserted or deleted the file.
            return ''
        try:
            with open(path, 'rb') as f:
                b = f.read()
            return g.toUnicode(b).replace('\r', '')
        except Exception:
            g.es_exception()
            return ''

    # @+node:ekr.20170806094320.9: *4* gdc.get_files
    def get_files(self, rev1: str, rev2: str, path: str | None = None) -> list[str]:
        """Return a list of changed files."""
        # #2143
        git_directory = self.get_parent_of_git_directory()
        if not git_directory:
            return []

        # Check that path exists relative to the git directory.
        if path:
            path_directory = f"{git_directory}{os.sep}{path}"
            if not os.path.exists(path_directory):
                g.trace(f"Not found: {path_directory}")
                return []
        command = f"git diff --name-only {(rev1 or '')} {(rev2 or '')}"
        if path:
            command = f"{command} -- {path}"
        # #1781: Allow diffs of .leo files.
        return [
            z.strip()
            for z in g.execGitCommand(command, git_directory)
            # #3994: binary files can corrupt the outline.
            if not z.strip().endswith(
                (
                    '.bmp',
                    '.db',
                    '.exe',
                    '.gif',
                    '.gz',
                    '.inv',  # Sphinx inventory file.
                    '.jpg',
                    '.mov',
                    '.mp4',
                    '.pdf',
                    '.png',
                    '.tar',
                    '.tif',
                    '.tar',
                    '.whl',
                    '.zip',
                )
            )
        ]

    # @+node:ekr.20170821052348.1: *4* gdc.get_revno
    def get_revno(self, revspec: str, abbreviated: bool = True) -> str:
        """Return the abbreviated hash for the given revision spec."""
        if not revspec:
            return 'uncommitted'
        # Return only the abbreviated hash for the revspec.
        format_s = 'h' if abbreviated else 'H'
        command = f"git show --format=%{format_s} --no-patch {revspec}"
        directory = self.get_parent_of_git_directory()
        lines = g.execGitCommand(command, directory=directory)
        return ''.join(lines).strip()

    # @+node:ekr.20170820084258.1: *4* gdc.make_at_clean_outline
    def make_at_clean_outline(self, fn: str, root: Position, s: str, rev: str) -> Cmdr:
        """
        Create a hidden temp outline from lines without sentinels.
        root is the @<file> node for fn.
        s is the contents of the (public) file, without sentinels.
        """
        # A specialized version of at.readOneAtCleanNode.
        hidden_c = leoCommands.Commands(fn, gui=g.app.nullGui)
        at = hidden_c.atFileCommands
        x = hidden_c.shadowController
        hidden_c.frame.createFirstTreeNode()
        hidden_root = hidden_c.rootPosition()
        # copy root to hidden root, including gnxs.
        root.copyTreeFromSelfTo(hidden_root, copyGnxs=True)
        hidden_root.h = fn + ':' + rev if rev else fn
        # Set at.encoding first.
        at.initReadIvars(hidden_root, fn)
        # Sets at.startSentinelComment/endSentinelComment.
        at.initSentinelComments(hidden_root)
        new_public_lines = g.splitLines(s)
        old_private_lines = at.write_at_clean_sentinels(hidden_root)
        marker = x.markerFromFileLines(old_private_lines, fn)
        old_public_lines, _ = x.separate_sentinels(old_private_lines, marker)
        if old_public_lines:
            # Fix #1136: The old lines might not exist.
            new_private_lines = x.propagate_changed_lines(
                new_public_lines, old_private_lines, marker, p=hidden_root
            )
            at.fast_read_into_root(
                c=hidden_c,
                contents=''.join(new_private_lines),
                gnx2vnode={},
                path=fn,
                root=hidden_root,
            )
        return hidden_c

    # @+node:ekr.20170806094321.7: *4* gdc.make_at_file_outline
    def make_at_file_outline(self, fn: str, s: str, rev: str) -> Cmdr | None:
        """Create a hidden temp outline from lines."""
        # A specialized version of atFileCommands.read.
        hidden_c = leoCommands.Commands(fn, gui=g.app.nullGui)
        at = hidden_c.atFileCommands
        hidden_c.frame.createFirstTreeNode()
        root = hidden_c.rootPosition()
        root.h = fn + ':' + rev if rev else fn
        at.initReadIvars(root, fn)
        if at.errors > 0:
            g.trace('***** errors')
            return None
        at.fast_read_into_root(
            c=hidden_c,
            contents=s,
            gnx2vnode={},
            path=fn,
            root=root,
        )
        return hidden_c

    # @+node:ekr.20170806125535.1: *4* gdc.make_diff_outlines & helper
    def make_diff_outlines(
        self,
        c1: Cmdr,
        c2: Cmdr,
        fn: str,
        rev1: str,
        rev2: str,
    ) -> None:
        """Create an outline-oriented diff from the *hidden* outlines c1 and c2."""
        branch, _commit = g.gitInfo()
        added, deleted, changed = self.compute_dicts(c1, c2)
        table = (
            (added,   'Added'),
            (deleted, 'Deleted'),
            (changed, 'Changed'),
        )  # fmt: skip
        for d, kind in table:
            self.create_compare_node(branch, c1, c2, d, kind, rev1, rev2)

    # @+node:ekr.20170806191707.1: *5* gdc.compute_dicts
    def compute_dicts(self, c1: Cmdr, c2: Cmdr) -> tuple[dict, dict, dict]:
        """Compute inserted, deleted, changed dictionaries."""
        # Special case the root: only compare the body text.
        root1, root2 = c1.rootPosition().v, c2.rootPosition().v
        root1.h = root2.h
        if 0:
            g.trace('c1...')
            for p in c1.all_positions():
                print(f"{len(p.b):4} {p.h}")
            g.trace('c2...')
            for p in c2.all_positions():
                print(f"{len(p.b):4} {p.h}")
        d1 = {v.fileIndex: v for v in c1.all_unique_nodes()}
        d2 = {v.fileIndex: v for v in c2.all_unique_nodes()}
        added = {key: d2.get(key) for key in d2 if not d1.get(key)}
        deleted = {key: d1.get(key) for key in d1 if not d2.get(key)}
        # Remove the root from the added and deleted dicts.
        if root2.fileIndex in added:
            del added[root2.fileIndex]
        if root1.fileIndex in deleted:
            del deleted[root1.fileIndex]
        changed = {}
        for key in d1:
            if key in d2:
                v1 = d1.get(key)
                v2 = d2.get(key)
                assert v1 and v2
                assert v1.context != v2.context
                if v1.h != v2.h or v1.b != v2.b:
                    changed[key] = (v1, v2)
        return added, deleted, changed

    # @+node:ekr.20260707060459.1: *4* gdc.make_clone_diff_outlines
    def make_clone_diff_outlines(
        self,
        c1: Cmdr,
        c2: Cmdr,
        fn: str,
        rev1: str = '',
        rev2: str = '',
    ) -> None:
        """
        Create an outline for the git-diff-pr-clone-changed command from
        *hidden* outlines c1 and c2.
        """
        branch, _commit = g.gitInfo()
        added, deleted, changed = self.compute_dicts(c1, c2)
        table = (
            (added,   'Added'),
            (deleted, 'Deleted'),
            (changed, 'Changed'),
        )  # fmt: skip
        for d, kind in table:
            self.create_clone_diff_node(branch, c1, c2, d, kind, rev1, rev2)

    # @+node:ekr.20201215050832.1: *4* gdc.make_leo_outline
    def make_leo_outline(self, fn: str, path: str, s: str, rev: str) -> Cmdr:
        """Create a hidden temp outline for the .leo file in s."""
        hidden_c = leoCommands.Commands(fn, gui=g.app.nullGui)
        hidden_c.frame.createFirstTreeNode()
        root = hidden_c.rootPosition()
        root.h = fn + ':' + rev if rev else fn
        fc = hidden_c.fileCommands
        fc.getAnyLeoFileByName(path, checkOpenFiles=False, readAtFileNodesFlag=False)
        return hidden_c

    # @+node:ekr.20260707055326.1: *4* gdc.clone_diff_two_revs
    def clone_diff_two_revs(
        self, rev1: str = 'HEAD', rev2: str = '', path: str | None = None
    ) -> None:
        """
        Like diff_two_revs showing only clones of changed nodes, organized by file:
        - Omit the diffs.
        - Don't show added or deleted nodes.
        """
        c, u = self.c, self.c.undoer

        if not self.get_parent_of_git_directory():
            return
        # Get list of changed files.
        files = self.get_files(rev1, rev2, path=path)
        n = len(files)
        if not g.unitTesting:
            g.es_print(f"diffing {n} file{g.plural(n)}")
            if n > 5:
                g.es_print('This may take a while...')
        c.selectPosition(c.lastTopLevel())  # pre-select to help undo-insert

        # Create the root node.
        undoData = u.beforeInsertNode(c.p)  # c.p is subject of 'insertAfter'
        self.root = c.lastTopLevel().insertAfter()
        root_h = f"clone-diff-pr revs: {rev1} {rev2}"
        if path and path.strip():
            root_h += f" ({path})"
        self.root.h = root_h
        self.root.b = '@ignore\n@nosearch\n'

        # Create diffs of all files.
        for i, fn in enumerate(files):
            self.clone_diff_file(fn=fn, rev1=rev1, rev2=rev2)
            if (i % 10) == 0:
                g.es_print(f"diff: {i} files", color='blue')

        u.afterInsertNode(self.root, 'clone-diff-pr', undoData)
        self.finish()
        g.es_print(f"done! diffed: {n} files")

    # @-others


# @-others
# @@language python
# @-leo
