diff options
author | Andrej Shadura <andrewsh@debian.org> | 2023-02-26 22:47:27 +0100 |
---|---|---|
committer | Andrej Shadura <andrewsh@debian.org> | 2023-02-26 22:47:27 +0100 |
commit | 55fb3bb8b5ada108958e0133f42b5b371afbef3c (patch) | |
tree | 4445864dce508f7a486a407a989b2c1a02186c8c | |
parent | b45961d0e98857b174681a27b2bf9c2abfba7a96 (diff) | |
parent | ef229d8ef0f468441954318afdada1c7c8b9f0c7 (diff) |
New upstream version 20230226.0
-rw-r--r-- | PKG-INFO | 6 | ||||
-rw-r--r-- | git-crecord.rst | 2 | ||||
-rw-r--r-- | git_crecord.egg-info/PKG-INFO | 6 | ||||
-rw-r--r-- | git_crecord.egg-info/SOURCES.txt | 3 | ||||
-rw-r--r-- | git_crecord.egg-info/entry_points.txt | 2 | ||||
-rw-r--r-- | git_crecord.egg-info/requires.txt | 9 | ||||
-rw-r--r-- | git_crecord/chunk_selector.py | 379 | ||||
-rw-r--r-- | git_crecord/crecord_core.py | 67 | ||||
-rw-r--r-- | git_crecord/crpatch.py | 298 | ||||
-rw-r--r-- | git_crecord/encoding.py | 5 | ||||
-rw-r--r-- | git_crecord/gitrepo.py | 35 | ||||
-rw-r--r-- | git_crecord/main.py | 130 | ||||
-rw-r--r-- | git_crecord/util.py | 84 | ||||
-rw-r--r-- | setup.cfg | 27 | ||||
-rwxr-xr-x | setup.py | 42 | ||||
-rw-r--r-- | tests/test_hunk_splitting.py | 75 |
16 files changed, 751 insertions, 419 deletions
@@ -1,12 +1,11 @@ Metadata-Version: 2.1 Name: git-crecord -Version: 20220324.0 +Version: 20230226.0 Summary: interactively select chunks to commit with Git Home-page: https://github.com/andrewshadura/git-crecord Author: Andrej Shadura Author-email: andrew@shadura.me License: GPL-2+ -Platform: UNKNOWN Classifier: Development Status :: 4 - Beta Classifier: Environment :: Console :: Curses Classifier: Intended Audience :: Developers @@ -18,6 +17,7 @@ Classifier: Programming Language :: Python :: 3.10 Classifier: Topic :: Software Development :: Version Control Requires-Python: >=3.9 Description-Content-Type: text/x-rst +Provides-Extra: lint Provides-Extra: test License-File: COPYING @@ -100,5 +100,3 @@ Authors ------- For the list of contributors, see CONTRIBUTORS. - - diff --git a/git-crecord.rst b/git-crecord.rst index 557bd5c..d6d5c93 100644 --- a/git-crecord.rst +++ b/git-crecord.rst @@ -8,7 +8,7 @@ interactively select changes to commit or stage :Author: Andrej Shadura <andrew@shadura.me> :Date: 2022-03-20 -:Version: 20220324.0 +:Version: 20230226.0 :Manual section: 1 :Manual group: Git diff --git a/git_crecord.egg-info/PKG-INFO b/git_crecord.egg-info/PKG-INFO index f9d920b..9563d56 100644 --- a/git_crecord.egg-info/PKG-INFO +++ b/git_crecord.egg-info/PKG-INFO @@ -1,12 +1,11 @@ Metadata-Version: 2.1 Name: git-crecord -Version: 20220324.0 +Version: 20230226.0 Summary: interactively select chunks to commit with Git Home-page: https://github.com/andrewshadura/git-crecord Author: Andrej Shadura Author-email: andrew@shadura.me License: GPL-2+ -Platform: UNKNOWN Classifier: Development Status :: 4 - Beta Classifier: Environment :: Console :: Curses Classifier: Intended Audience :: Developers @@ -18,6 +17,7 @@ Classifier: Programming Language :: Python :: 3.10 Classifier: Topic :: Software Development :: Version Control Requires-Python: >=3.9 Description-Content-Type: text/x-rst +Provides-Extra: lint Provides-Extra: test License-File: COPYING @@ -100,5 +100,3 @@ Authors ------- For the list of contributors, see CONTRIBUTORS. - - diff --git a/git_crecord.egg-info/SOURCES.txt b/git_crecord.egg-info/SOURCES.txt index 7bc5c33..e257059 100644 --- a/git_crecord.egg-info/SOURCES.txt +++ b/git_crecord.egg-info/SOURCES.txt @@ -21,4 +21,5 @@ git_crecord.egg-info/SOURCES.txt git_crecord.egg-info/dependency_links.txt git_crecord.egg-info/entry_points.txt git_crecord.egg-info/requires.txt -git_crecord.egg-info/top_level.txt
\ No newline at end of file +git_crecord.egg-info/top_level.txt +tests/test_hunk_splitting.py
\ No newline at end of file diff --git a/git_crecord.egg-info/entry_points.txt b/git_crecord.egg-info/entry_points.txt index 8dd4c5a..e67fe9b 100644 --- a/git_crecord.egg-info/entry_points.txt +++ b/git_crecord.egg-info/entry_points.txt @@ -1,3 +1,3 @@ [console_scripts] git-crecord = git_crecord.main:main - +git-cstage = git_crecord.main:main diff --git a/git_crecord.egg-info/requires.txt b/git_crecord.egg-info/requires.txt index 2b9979b..2f96368 100644 --- a/git_crecord.egg-info/requires.txt +++ b/git_crecord.egg-info/requires.txt @@ -1,3 +1,12 @@ +[lint] +flake8 +flake8-blind-except +flake8-builtins +flake8-commas +flake8-comprehensions +flake8-docstrings +flake8-isort + [test] pytest>=6 diff --git a/git_crecord/chunk_selector.py b/git_crecord/chunk_selector.py index 72540c0..8b70ab5 100644 --- a/git_crecord/chunk_selector.py +++ b/git_crecord/chunk_selector.py @@ -11,24 +11,19 @@ # SPDX-License-Identifier: GPL-2.0-or-later from __future__ import annotations -from collections.abc import MutableSequence, Sequence -from gettext import gettext as _ - -from . import util - -from . import encoding - -import re -import sys -import struct -import signal - -from .crpatch import PatchRoot, Header, Hunk, HunkLine - import curses import fcntl +import re +import signal +import struct +import sys import termios +from collections.abc import MutableSequence, Sequence +from gettext import gettext as _ +from textwrap import dedent +from . import encoding, util +from .crpatch import Header, Hunk, HunkLine, PatchRoot _origstdout = sys.__stdout__ # used by gethw() @@ -43,7 +38,8 @@ def gethw() -> tuple[int, int]: """ h, w = struct.unpack( - "hhhh", fcntl.ioctl(_origstdout, termios.TIOCGWINSZ, b"\0"*8))[0:2] + "hhhh", fcntl.ioctl(_origstdout, termios.TIOCGWINSZ, b"\0" * 8), + )[0:2] return h, w @@ -57,6 +53,7 @@ def chunkselector(opts, headerlist, ui): # This is required for ncurses to display non-ASCII characters in default user # locale encoding correctly. --immerrr import locale + locale.setlocale(locale.LC_ALL, '') class DummyStdscr: @@ -76,14 +73,21 @@ def chunkselector(opts, headerlist, ui): signal.signal(signal.SIGTSTP, f) -_headermessages = { +header_messages = { # {operation: text} 'crecord': _('Select hunks to commit'), 'cstage': _('Select hunks to stage'), 'cunstage': _('Select hunks to keep'), } -_confirmmessages = { +main_operation_messages = { + # {operation: text} + 'crecord': _('c: commit'), + 'cstage': _('s: stage'), + 'cunstage': None, # TODO: not implemented! +} + +confirm_messages = { 'crecord': _('Are you sure you want to commit the selected changes [Yn]?'), 'cstage': _('Are you sure you want to stage the selected changes [Yn]?'), 'cunstage': _('Are you sure you want to unstage the unselected changes [Yn]?'), @@ -143,9 +147,7 @@ class CursesChunkSelector: self.waslasttoggleallapplied = True def handlefirstlineevent(self): - """ - Handle 'g' to navigate to the top most file in the ncurses window. - """ + """Handle 'g' to navigate to the top most file in the ncurses window.""" self.currentselecteditem = self.headerlist[0] currentitem = self.currentselecteditem # select the parent item recursively until we're at a header @@ -159,9 +161,9 @@ class CursesChunkSelector: self.currentselecteditem = currentitem def handlelastlineevent(self): - """ - Handle 'G' to navigate to the bottom most file/hunk/line depending - on the whether the fold is active or not. + """Handle 'G' to navigate to the bottom most file/hunk/line. + + The behaviour depends on the whether the fold is active or not. If the bottom most file is folded, it navigates to that file and stops there. If the bottom most file is unfolded, it navigates to the bottom most hunk in @@ -228,7 +230,6 @@ class CursesChunkSelector: a hunk is currently selected, then select the next hunk, if one exists, or if not, the next header if one exists. """ - #self.startprintline += 1 #debug currentitem = self.currentselecteditem nextitem = currentitem.nextitem() @@ -262,10 +263,7 @@ class CursesChunkSelector: self.recenterdisplayedarea() def rightarrowevent(self): - """ - Select (if possible) the first of this item's child-items. - - """ + """Select (if possible) the first of this item's child-items.""" currentitem = self.currentselecteditem nextitem = currentitem.firstchild() @@ -281,11 +279,11 @@ class CursesChunkSelector: self.recenterdisplayedarea() def leftarrowevent(self): - """ + """Select parent or fold. + If the current item can be folded (i.e. it is an unfolded header or - hunk), then fold it. Otherwise try select (if possible) the parent + hunk), then fold it. Otherwise, try to select (if possible) the parent of this item. - """ currentitem = self.currentselecteditem @@ -335,7 +333,6 @@ class CursesChunkSelector: """Scroll the screen to fully show the currently-selected""" selstart = self.selecteditemstartline selend = self.selecteditemendline - #selnumlines = selend - selstart padstart = self.firstlineofpadtoprint padend = padstart + self.yscreensize - self.numstatuslines - 1 # 'buffered' pad start/end values which scroll with a certain @@ -392,13 +389,13 @@ class CursesChunkSelector: hunkline.applied = item.applied siblingappliedstatus = [hnk.applied for hnk in item.header.hunks] - allsiblingsapplied = not (False in siblingappliedstatus) - nosiblingsapplied = not (True in siblingappliedstatus) + allsiblingsapplied = all(siblingappliedstatus) + nosiblingsapplied = not any(siblingappliedstatus) siblingspartialstatus = [hnk.partial for hnk in item.header.hunks] - somesiblingspartial = (True in siblingspartialstatus) + somesiblingspartial = any(siblingspartialstatus) - #cases where applied or partial should be removed from header + # cases where applied or partial should be removed from header # if no 'sibling' hunks are applied (including this hunk) if nosiblingsapplied: @@ -407,13 +404,11 @@ class CursesChunkSelector: item.header.partial = False else: # some/all parent siblings are applied item.header.applied = True - item.header.partial = (somesiblingspartial or - not allsiblingsapplied) - + item.header.partial = somesiblingspartial or not allsiblingsapplied elif isinstance(item, HunkLine): siblingappliedstatus = [ln.applied for ln in item.hunk.changedlines] - allsiblingsapplied = not (False in siblingappliedstatus) - nosiblingsapplied = not (True in siblingappliedstatus) + allsiblingsapplied = all(siblingappliedstatus) + nosiblingsapplied = not any(siblingappliedstatus) # if no 'sibling' lines are applied if nosiblingsapplied: @@ -426,14 +421,12 @@ class CursesChunkSelector: item.hunk.applied = True item.hunk.partial = True - parentsiblingsapplied = [hnk.applied for hnk - in item.hunk.header.hunks] - noparentsiblingsapplied = not (True in parentsiblingsapplied) - allparentsiblingsapplied = not (False in parentsiblingsapplied) + parentsiblingsapplied = [hnk.applied for hnk in item.hunk.header.hunks] + noparentsiblingsapplied = not any(parentsiblingsapplied) + allparentsiblingsapplied = all(parentsiblingsapplied) - parentsiblingspartial = [hnk.partial for hnk - in item.hunk.header.hunks] - someparentsiblingspartial = (True in parentsiblingspartial) + parentsiblingspartial = [hnk.partial for hnk in item.hunk.header.hunks] + someparentsiblingspartial = any(parentsiblingspartial) # if all parent hunks are not applied, un-apply header if noparentsiblingsapplied: @@ -443,8 +436,7 @@ class CursesChunkSelector: # set the applied and partial status of the header if needed else: # some/all parent siblings are applied item.hunk.header.applied = True - item.hunk.header.partial = (someparentsiblingspartial or - not allparentsiblingsapplied) + item.hunk.header.partial = someparentsiblingspartial or not allparentsiblingsapplied def toggleall(self): """Toggle the applied flag of all items.""" @@ -477,7 +469,6 @@ class CursesChunkSelector: if isinstance(item, (Header, Hunk)): item.folded = not item.folded - def alignstring(self, instr, window): """ Add whitespace to the end of a string in order to make it fill @@ -494,8 +485,19 @@ class CursesChunkSelector: numspaces = width - ((strwidth + xstart) % width) return instr + " " * numspaces - def printstring(self, window, text, fgcolor=None, bgcolor=None, pair=None, - pairname=None, attrlist=None, towin=True, align=True, showwhtspc=False): + def printstring( + self, + window, + text, + fgcolor=None, + bgcolor=None, + pair=None, + pairname=None, + attrlist=None, + towin=True, + align=True, + showwhtspc=False, + ): """ Print the string, text, with the specified colors and attributes, to the specified curses window object. @@ -520,7 +522,7 @@ class CursesChunkSelector: # Strip \n, and convert control characters to ^[char] representation text = re.sub( r'[\x00-\x08\x0a-\x1f]', - lambda m: '^' + chr(ord(m.group()) + 64), text.strip('\n') + lambda m: '^' + chr(ord(m.group()) + 64), text.strip('\n'), ) if pair is not None: @@ -588,10 +590,10 @@ class CursesChunkSelector: """-> [str]. return segments""" selected = self.currentselecteditem.applied segments = [ - _headermessages[self.opts['operation']], + header_messages[self.opts['operation']], '-', _('[x]=selected **=collapsed'), - _('c: confirm'), + main_operation_messages[self.opts['operation']], _('q: abort'), _('arrow keys: move/expand/collapse'), _('space: deselect') if selected else _('space: select'), @@ -643,10 +645,12 @@ class CursesChunkSelector: try: self.printitem() self.updatescroll() - self.chunkpad.refresh(self.firstlineofpadtoprint, 0, - self.numstatuslines, 0, - self.yscreensize - self.numstatuslines, - self.xscreensize) + self.chunkpad.refresh( + self.firstlineofpadtoprint, 0, + self.numstatuslines, 0, + self.yscreensize - self.numstatuslines, + self.xscreensize, + ) except curses.error: pass @@ -683,8 +687,9 @@ class CursesChunkSelector: return checkbox - def printheader(self, header: Header, selected=False, towin=True, - ignorefolding=False): + def printheader( + self, header: Header, selected=False, towin=True, ignorefolding=False, + ): """ Print the header to the pad. If countLines is True, don't print anything, but just count the number of lines which would be printed. @@ -696,11 +701,13 @@ class CursesChunkSelector: if chunkindex != 0 and not header.folded: # add separating line before headers - outstr += self.printstring(self.chunkpad, '_' * self.xscreensize, - towin=towin, align=False) + outstr += self.printstring( + self.chunkpad, "_" * self.xscreensize, towin=towin, align=False, + ) # select color-pair based on if the header is selected - colorpair = self.getcolorpair(name=selected and "selected" or "normal", - attrlist=[curses.A_BOLD]) + colorpair = self.getcolorpair( + name=selected and "selected" or "normal", attrlist=[curses.A_BOLD], + ) # print out each line of the chunk, expanding it to screen width @@ -712,42 +719,45 @@ class CursesChunkSelector: linestr = checkbox + textlist[0] else: linestr = checkbox + header.filename() - outstr += self.printstring(self.chunkpad, linestr, pair=colorpair, - towin=towin) + outstr += self.printstring(self.chunkpad, linestr, pair=colorpair, towin=towin) if not header.folded or ignorefolding: if len(textlist) > 1: for line in textlist[1:]: - linestr = " "*(indentnumchars + len(checkbox)) + line - outstr += self.printstring(self.chunkpad, linestr, - pair=colorpair, towin=towin) + linestr = " " * (indentnumchars + len(checkbox)) + line + outstr += self.printstring( + self.chunkpad, linestr, pair=colorpair, towin=towin, + ) return outstr - def printhunklinesbefore(self, hunk: Hunk, selected=False, towin=True, - ignorefolding=False): - """includes start/end line indicator""" + def printhunklinesbefore( + self, hunk: Hunk, selected=False, towin=True, ignorefolding=False, + ): + """Print lines including the start/end line indicator.""" outstr = "" # where hunk is in list of siblings hunkindex = hunk.header.hunks.index(hunk) if hunkindex != 0: # add separating line before headers - outstr += self.printstring(self.chunkpad, ' '*self.xscreensize, - towin=towin, align=False) + outstr += self.printstring( + self.chunkpad, " " * self.xscreensize, towin=towin, align=False, + ) - colorpair = self.getcolorpair(name=selected and "selected" or "normal", - attrlist=[curses.A_BOLD]) + colorpair = self.getcolorpair( + name=selected and "selected" or "normal", attrlist=[curses.A_BOLD], + ) # print out from-to line with checkbox checkbox = self.getstatusprefixstring(hunk) - lineprefix = " "*self.hunkindentnumchars + checkbox + lineprefix = " " * self.hunkindentnumchars + checkbox frtoline = " " + hunk.getfromtoline().decode("UTF-8", errors="hexreplace").strip("\n") - outstr += self.printstring(self.chunkpad, lineprefix, towin=towin, - align=False) # add uncolored checkbox/indent - outstr += self.printstring(self.chunkpad, frtoline, pair=colorpair, - towin=towin) + outstr += self.printstring( + self.chunkpad, lineprefix, towin=towin, align=False, + ) # add uncolored checkbox/indent + outstr += self.printstring(self.chunkpad, frtoline, pair=colorpair, towin=towin) if hunk.folded and not ignorefolding: # skip remainder of output @@ -755,7 +765,7 @@ class CursesChunkSelector: # print out lines of the chunk preceding changed-lines for line in hunk.before: - linestr = " "*(self.hunklineindentnumchars + len(checkbox)) + line.decode("UTF-8", errors="hexreplace") + linestr = " " * (self.hunklineindentnumchars + len(checkbox)) + line.decode("UTF-8", errors="hexreplace") outstr += self.printstring(self.chunkpad, linestr, towin=towin) return outstr @@ -768,7 +778,7 @@ class CursesChunkSelector: # a bit superfluous, but to avoid hard-coding indent amount checkbox = self.getstatusprefixstring(hunk) for line in hunk.after: - linestr = " "*(self.hunklineindentnumchars + len(checkbox)) + line.decode("UTF-8", errors="hexreplace") + linestr = " " * (self.hunklineindentnumchars + len(checkbox)) + line.decode("UTF-8", errors="hexreplace") outstr += self.printstring(self.chunkpad, linestr, towin=towin) return outstr @@ -789,17 +799,20 @@ class CursesChunkSelector: elif linestr.startswith("\\"): colorpair = self.getcolorpair(name="normal") - lineprefix = " "*self.hunklineindentnumchars + checkbox - outstr += self.printstring(self.chunkpad, lineprefix, towin=towin, - align=False) # add uncolored checkbox/indent - outstr += self.printstring(self.chunkpad, linestr, pair=colorpair, - towin=towin, showwhtspc=True) + lineprefix = " " * self.hunklineindentnumchars + checkbox + outstr += self.printstring( + self.chunkpad, lineprefix, towin=towin, align=False, + ) # add uncolored checkbox/indent + outstr += self.printstring( + self.chunkpad, linestr, pair=colorpair, towin=towin, showwhtspc=True, + ) return outstr - def printitem(self, item=None, ignorefolding=False, recursechildren=True, - towin=True): - """ - Use __printitem() to print the specified item.applied. + def printitem( + self, item=None, ignorefolding=False, recursechildren=True, towin=True, + ): + """Print the specified item. + If item is not specified, then print the entire patch. (hiding folded elements, etc. -- see __printitem() docstring) """ @@ -810,7 +823,7 @@ class CursesChunkSelector: outstr = [] self.__printitem( - item, ignorefolding, recursechildren, outstr, towin=towin + item, ignorefolding, recursechildren, outstr, towin=towin, ) return ''.join(outstr) @@ -824,14 +837,13 @@ class CursesChunkSelector: return y < miny or y > maxy def handleselection(self, item, recursechildren): - selected = (item is self.currentselecteditem) + selected = item is self.currentselecteditem if selected and recursechildren: # assumes line numbering starting from line 0 self.selecteditemstartline = self.linesprintedtopadsofar - selecteditemlines = self.getnumlinesdisplayed(item, - recursechildren=False) - self.selecteditemendline = (self.selecteditemstartline + - selecteditemlines - 1) + selecteditemlines = self.getnumlinesdisplayed(item, recursechildren=False) + self.selecteditemendline = self.selecteditemstartline + selecteditemlines - 1 + return selected def __printitem( @@ -862,36 +874,42 @@ class CursesChunkSelector: if isinstance(item, PatchRoot): if recursechildren: for hdr in item: - self.__printitem(hdr, ignorefolding, - recursechildren, outstr, towin) + self.__printitem(hdr, ignorefolding, recursechildren, outstr, towin) # TODO: eliminate all isinstance() calls if isinstance(item, Header): - outstr.append(self.printheader(item, selected, towin=towin, - ignorefolding=ignorefolding)) + outstr.append( + self.printheader( + item, selected, towin=towin, ignorefolding=ignorefolding, + ), + ) if recursechildren: for hnk in item.hunks: - self.__printitem(hnk, ignorefolding, - recursechildren, outstr, towin) - elif (isinstance(item, Hunk) and - ((not item.header.folded) or ignorefolding)): + self.__printitem(hnk, ignorefolding, recursechildren, outstr, towin) + elif isinstance(item, Hunk) and ((not item.header.folded) or ignorefolding): # print the hunk data which comes before the changed-lines - outstr.append(self.printhunklinesbefore(item, selected, towin=towin, - ignorefolding=ignorefolding)) + outstr.append( + self.printhunklinesbefore( + item, selected, towin=towin, ignorefolding=ignorefolding, + ), + ) if recursechildren: for line in item.changedlines: - self.__printitem(line, ignorefolding, - recursechildren, outstr, towin) - outstr.append(self.printhunklinesafter(item, towin=towin, - ignorefolding=ignorefolding)) - elif (isinstance(item, HunkLine) and - ((not item.hunk.folded) or ignorefolding)): - outstr.append(self.printhunkchangedline(item, selected, - towin=towin)) + self.__printitem( + line, ignorefolding, recursechildren, outstr, towin, + ) + outstr.append( + self.printhunklinesafter( + item, towin=towin, ignorefolding=ignorefolding, + ), + ) + elif isinstance(item, HunkLine) and ((not item.hunk.folded) or ignorefolding): + outstr.append(self.printhunkchangedline(item, selected, towin=towin)) return outstr - def getnumlinesdisplayed(self, item=None, ignorefolding=False, - recursechildren=True): + def getnumlinesdisplayed( + self, item=None, ignorefolding=False, recursechildren=True, + ): """ Return the number of lines which would be displayed if the item were to be printed to the display. The item will NOT be printed to the @@ -902,8 +920,9 @@ class CursesChunkSelector: """ # temporarily disable printing to windows by printstring - patchdisplaystring = self.printitem(item, ignorefolding, - recursechildren, towin=False) + patchdisplaystring = self.printitem( + item, ignorefolding, recursechildren, towin=False, + ) numlines = len(patchdisplaystring) // self.xscreensize return numlines @@ -919,8 +938,7 @@ class CursesChunkSelector: except curses.error: pass - def getcolorpair(self, fgcolor=None, bgcolor=None, name=None, - attrlist=None): + def getcolorpair(self, fgcolor=None, bgcolor=None, name=None, attrlist=None): """ Get a curses color pair, adding it to self.colorPairs if it is not already defined. An optional string, name, can be passed as a shortcut @@ -950,8 +968,9 @@ class CursesChunkSelector: pairindex = len(self.colorpairs) + 1 if self.usecolor: curses.init_pair(pairindex, fgcolor, bgcolor) - colorpair = self.colorpairs[(fgcolor, bgcolor)] = ( - curses.color_pair(pairindex)) + colorpair = self.colorpairs[(fgcolor, bgcolor)] = curses.color_pair( + pairindex, + ) if name is not None: self.colorpairnames[name] = curses.color_pair(pairindex) else: @@ -976,10 +995,6 @@ class CursesChunkSelector: colorpair |= textattrib return colorpair - def initcolorpair(self, *args, **kwargs): - """Same as getcolorpair.""" - self.getcolorpair(*args, **kwargs) - def helpwindow(self): """Print a help window to the screen. Exit after any keypress.""" helptext = """ [press any key to return to the patch-display] @@ -1011,8 +1026,9 @@ The following are valid keystrokes: helpwin = curses.newwin(self.yscreensize, 0, 0, 0) helplines = helptext.split("\n") - helplines = helplines + [" "]*( - self.yscreensize - self.numstatuslines - len(helplines) - 1) + helplines = helplines + [" "] * ( + self.yscreensize - self.numstatuslines - len(helplines) - 1 + ) try: self.printstring(helpwin, helplines[0], pairname="legend") for line in helplines[1:]: @@ -1027,7 +1043,6 @@ The following are valid keystrokes: def confirmationwindow(self, windowtext): """Display an informational window, then wait for and return a keypress.""" - lines = windowtext.split("\n") confirmwin = curses.newwin(len(lines), 0, 0, 0) try: @@ -1049,35 +1064,36 @@ The following are valid keystrokes: return True if review: - confirmtext = ( -"""If you answer yes to the following, the your currently chosen patch chunks -will be loaded into an editor. You may modify the patch from the editor, and -save the changes if you wish to change the patch. Otherwise, you can just -close the editor without saving to accept the current patch as-is. - -NOTE: don't add/remove lines unless you also modify the range information. - Failing to follow this rule will result in the commit aborting. - -Are you sure you want to review/edit and confirm the selected changes [Yn]? -""") + confirmtext = dedent( + """ + If you answer yes to the following, the your currently chosen patch chunks + will be loaded into an editor. You may modify the patch from the editor, and + save the changes if you wish to change the patch. Otherwise, you can just + close the editor without saving to accept the current patch as-is. + + NOTE: don't add/remove lines unless you also modify the range information. + Failing to follow this rule will result in the commit aborting. + + Are you sure you want to review/edit and confirm the selected changes [Yn]? + """, + ) else: - confirmtext = _confirmmessages[self.opts['operation']] + confirmtext = confirm_messages[self.opts['operation']] response = self.confirmationwindow(confirmtext) if response is None or len(response) == 0 or response == "\n": response = "y" - if response.lower().startswith("y"): - return True - else: - return False + return response.lower().startswith("y") def recenterdisplayedarea(self): """ - once we scrolled with pg up pg down we can be pointing outside of the - display zone. we print the patch with towin=False to compute the - location of the selected item even though it is outside of the displayed - zone and then update the scroll. + Recenter the screen. + + Once we scrolled with PgUp/PgDown, we can be pointing outside the + display zone. We print the patch with towin=False to compute the + location of the selected item even though it is outside the displayed + zone, and then update the scroll. """ self.printitem(towin=False) self.updatescroll() @@ -1088,31 +1104,25 @@ Are you sure you want to review/edit and confirm the selected changes [Yn]? When the amend flag is set, a commit will modify the most recent commit, instead of creating a new commit. Otherwise, a new commit will be created (the normal commit behavior). - """ if not self.opts.get('amend'): self.opts['amend'] = True - msg = ("Amend option is turned on -- committing the currently " - "selected changes will not create a new commit, but " - "instead update the most recent commit.\n\n" - "Press any key to continue.") + msg = ( + "Amend option is turned on -- committing the currently " + "selected changes will not create a new commit, but " + "instead update the most recent commit.\n\n" + "Press any key to continue." + ) else: self.opts['amend'] = False - msg = ("Amend option is turned off -- committing the currently " - "selected changes will create a new commit.\n\n" - "Press any key to continue.") + msg = ( + "Amend option is turned off -- committing the currently " + "selected changes will create a new commit.\n\n" + "Press any key to continue." + ) self.confirmationwindow(msg) - def emptypatch(self): - item = self.headerlist - if not item: - return True - for header in item: - if header.hunks: - return False - return True - def handlekeypressed(self, keypressed): """ Perform actions based on pressed keys. @@ -1138,13 +1148,14 @@ Are you sure you want to review/edit and confirm the selected changes [Yn]? elif keypressed in ['a']: self.toggleamend() elif keypressed in ["c"]: + self.opts['confirm'] |= self.opts['operation'] != 'crecord' if self.confirmcommit(): - self.opts['commit'] = True + self.opts['operation'] = 'crecord' return True elif keypressed in ["s"]: - self.opts['commit'] = False + self.opts['confirm'] |= self.opts['operation'] != 'cstage' if self.confirmcommit(): - self.opts['commit'] = False + self.opts['operation'] = 'cstage' return True elif keypressed in ["r"]: if self.confirmcommit(review=True): @@ -1176,16 +1187,15 @@ Are you sure you want to review/edit and confirm the selected changes [Yn]? return False def main(self, stdscr, opts): - """ - Method to be wrapped by curses.wrapper() for selecting chunks. + """Configure and restore signal handler, and run the record UI. + Method to be wrapped by curses.wrapper() for selecting chunks. """ self.opts = opts origsigwinch = sentinel = object() if util.safehasattr(signal, 'SIGWINCH'): - origsigwinch = signal.signal(signal.SIGWINCH, - self.sigwinchhandler) + origsigwinch = signal.signal(signal.SIGWINCH, self.sigwinchhandler) try: return self._main(stdscr) finally: @@ -1193,6 +1203,7 @@ Are you sure you want to review/edit and confirm the selected changes [Yn]? signal.signal(signal.SIGWINCH, origsigwinch) def _main(self, stdscr): + """Configure the display and run the event loop.""" self.stdscr = stdscr # error during initialization, cannot be printed in the curses # interface, it should be printed by the calling code @@ -1219,12 +1230,11 @@ Are you sure you want to review/edit and confirm the selected changes [Yn]? # available colors: black, blue, cyan, green, magenta, white, yellow # init_pair(color_id, foreground_color, background_color) - self.initcolorpair(None, None, name="normal") - self.initcolorpair(curses.COLOR_WHITE, curses.COLOR_MAGENTA, - name="selected") - self.initcolorpair(curses.COLOR_RED, None, name="deletion") - self.initcolorpair(curses.COLOR_GREEN, None, name="addition") - self.initcolorpair(curses.COLOR_WHITE, curses.COLOR_BLUE, name="legend") + self.getcolorpair(None, None, name="normal") + self.getcolorpair(curses.COLOR_WHITE, curses.COLOR_MAGENTA, name="selected") + self.getcolorpair(curses.COLOR_RED, None, name="deletion") + self.getcolorpair(curses.COLOR_GREEN, None, name="addition") + self.getcolorpair(curses.COLOR_WHITE, curses.COLOR_BLUE, name="legend") # newwin([height, width,] begin_y, begin_x) self.statuswin = curses.newwin(self.numstatuslines, 0, 0, 0) self.statuswin.keypad(True) # interpret arrow-key, etc. ESC sequences @@ -1245,7 +1255,8 @@ Are you sure you want to review/edit and confirm the selected changes [Yn]? return # initialize selecteditemendline (initial start-line is 0) self.selecteditemendline = self.getnumlinesdisplayed( - self.currentselecteditem, recursechildren=False) + self.currentselecteditem, recursechildren=False, + ) # option which enables/disables patch-review (in editor) step self.opts['crecord_reviewpatch'] = False diff --git a/git_crecord/crecord_core.py b/git_crecord/crecord_core.py index 934fc3a..3a82bbb 100644 --- a/git_crecord/crecord_core.py +++ b/git_crecord/crecord_core.py @@ -10,27 +10,25 @@ # # SPDX-License-Identifier: GPL-2.0-or-later -from gettext import gettext as _ - -import io import errno +import io import os -import tempfile import subprocess +import tempfile +from gettext import gettext as _ from typing import IO, cast -from .crpatch import Header, parsepatch, filterpatch from .chunk_selector import chunkselector -from .gitrepo import GitRepo -from .util import Abort, system, closefds, copyfile +from .crpatch import Header, filterpatch, parsepatch +from .util import Abort, closefds, copyfile, system def dorecord(ui, repo, *pats, **opts): - """This is generic record driver. + """Drive the record process - Its job is to interactively filter local changes, and accordingly - prepare working dir into a state, where the job can be delegated to - non-interactive commit command such as 'commit' or 'qrefresh'. + Interactively filter local changes, and accordingly prepare working + directory into a state, where the job can be delegated to + non-interactive commit command such as 'commit'. After the actual job is done by non-interactive command, working dir state is restored to original. @@ -48,8 +46,15 @@ def dorecord(ui, repo, *pats, **opts): "a/" and "b/" depending on what is being compared. Our parser only supports "a/" and "b/". """ - - git_args = ["git", "-c", "core.quotePath=false", "-c", "diff.mnemonicPrefix=false", "diff", "--binary"] + git_args = [ + "git", + "-c", + "core.quotePath=false", + "-c", + "diff.mnemonicPrefix=false", + "diff", + "--binary", + ] git_base = [] if opts['cached']: @@ -58,7 +63,9 @@ def dorecord(ui, repo, *pats, **opts): if not opts['index'] and repo.head(): git_base.append("HEAD") - p = subprocess.Popen(git_args + git_base, stdout=subprocess.PIPE, close_fds=closefds) + p = subprocess.Popen( + git_args + git_base, stdout=subprocess.PIPE, close_fds=closefds, + ) fp = cast(IO[bytes], p.stdout) # 0. parse patch @@ -80,9 +87,7 @@ def dorecord(ui, repo, *pats, **opts): changes = [modified, added, removed] # 1. filter patch, so we have intending-to apply subset of it - chunks = filterpatch(opts, - chunks, - chunkselector, ui) + chunks = filterpatch(opts, chunks, chunkselector, ui) p.wait() del fp @@ -120,10 +125,12 @@ def dorecord(ui, repo, *pats, **opts): if f not in (modified | added): continue prefix = os.fsdecode(f).replace('/', '_') + '.' - fd, tmpname = tempfile.mkstemp(prefix=prefix, - dir=backupdir) + fd, tmpname = tempfile.mkstemp( + prefix=prefix, + dir=backupdir, + ) os.close(fd) - ui.debug('backup %r as %r' % (f, tmpname)) + ui.debug(f'backup {f!r} as {tmpname!r}') pathname = repo.path / f if os.path.isfile(pathname): copyfile(pathname, tmpname) @@ -150,9 +157,15 @@ def dorecord(ui, repo, *pats, **opts): fp.seek(0) # 3a. apply filtered patch to clean repo (clean) - if backups or any((f in contenders for f in removed)): - system(['git', 'checkout', '-f'] + git_base + ['--'] + [f for f in newfiles if f not in added], - onerr=Abort, errprefix=_("checkout failed")) + if backups or any(f in contenders for f in removed): + system( + ["git", "checkout", "-f"] + + git_base + + ["--"] + + [f for f in newfiles if f not in added], + onerr=Abort, + errprefix=_("checkout failed"), + ) # remove newly added files from 'clean' repo (so patch can apply) for f in newly_added_backups: pathname = repo.path / f @@ -166,12 +179,12 @@ def dorecord(ui, repo, *pats, **opts): p = subprocess.Popen( ["git", "apply", "--whitespace=nowarn"], stdin=subprocess.PIPE, - close_fds=closefds + close_fds=closefds, ) p.stdin.write(fp.getvalue()) p.stdin.close() p.wait() - except Exception as err: + except Exception as err: # noqa: B902 s = str(err) if s: raise Abort(s) @@ -197,11 +210,11 @@ def dorecord(ui, repo, *pats, **opts): # 5. finally restore backed-up files try: for realname, tmpname in backups.items(): - ui.debug('restoring %r to %r' % (tmpname, realname)) + ui.debug(f'restoring {tmpname!r} to {realname!r}') copyfile(tmpname, os.path.join(repo.path, realname)) os.unlink(tmpname) for realname, tmpname in newly_added_backups.items(): - ui.debug('restoring %r to %r' % (tmpname, realname)) + ui.debug(f'restoring {tmpname!r} to {realname!r}') copyfile(tmpname, os.path.join(repo.path, realname)) os.unlink(tmpname) os.rmdir(backupdir) diff --git a/git_crecord/crpatch.py b/git_crecord/crpatch.py index 365e66d..c0dbea1 100644 --- a/git_crecord/crpatch.py +++ b/git_crecord/crpatch.py @@ -10,14 +10,12 @@ # # SPDX-License-Identifier: GPL-2.0-or-later -# stuff related specifically to patch manipulation / parsing -from gettext import gettext as _ - import io import re from codecs import register_error - -from typing import IO, Iterator, Optional, Sequence, Union +from gettext import gettext as _ +from typing import IO, Optional, Union +from collections.abc import Iterator, Sequence from .util import unwrap_filename @@ -132,9 +130,11 @@ def scanpatch(fp: IO[bytes]): lr.push(fromfile) yield 'file', header elif line.startswith(b' '): - yield 'context', scanwhile(line, lambda l: l[0] in b' \\') + yield 'context', scanwhile(line, lambda l: l[0] in b' ') elif line[0] in b'-+': yield 'hunk', scanwhile(line, lambda l: l[0] in b'-+\\') + elif line.startswith(b'* Unmerged path '): + continue else: m = lines_re.match(line) if m: @@ -149,6 +149,7 @@ class PatchNode: """ folded: bool + applied: bool # a patch this node belongs to patch: 'PatchRoot' @@ -246,11 +247,9 @@ class PatchNode: prevsibling = self.prevsibling() if prevsibling is not None: prevsiblinglastchild = prevsibling.lastchild() - if ((prevsiblinglastchild is not None) and - not prevsibling.folded): + if (prevsiblinglastchild is not None) and not prevsibling.folded: prevsiblinglclc = prevsiblinglastchild.lastchild() - if ((prevsiblinglclc is not None) and - not prevsiblinglastchild.folded): + if (prevsiblinglclc is not None) and not prevsiblinglastchild.folded: return prevsiblinglclc else: return prevsiblinglastchild @@ -275,6 +274,7 @@ class PatchNode: class Header(PatchNode): """Patch header""" + diff_re = re.compile(b'diff --git (?P<fromfile>(?P<aq>")?a/.*(?(aq)"|)) (?P<tofile>(?P<bq>")?b/.*(?(bq)"|))$') allhunks_re = re.compile(b'(?:GIT binary patch|new file|deleted file) ') pretty_re = re.compile(b'(?:new file|deleted file) ') @@ -299,14 +299,11 @@ class Header(PatchNode): self._changetype = None def binary(self): - """ - Return True if the file represented by the header is a binary file. - Otherwise return False. - - """ + """Return True if the file represented by the header is a binary file.""" return any(h.startswith(b'GIT binary patch') for h in self.header) def pretty(self, fp: IO[str]): + """Pretty-print the header into a stream""" for h in self.header: if h.startswith(b'GIT binary patch'): fp.write(_('this modifies a binary file (all or nothing)\n')) @@ -317,9 +314,13 @@ class Header(PatchNode): fp.write(_('this is a binary file\n')) break if h.startswith(b'---'): - fp.write(_('%d hunks, %d lines changed\n') % - (len(self.hunks), - sum([max(h.added, h.removed) for h in self.hunks]))) + fp.write( + _('%d hunks, %d lines changed\n') % + ( + len(self.hunks), + sum(max(h.added, h.removed) for h in self.hunks), + ), + ) break fp.write(h.decode("UTF-8", errors="hexreplace")) @@ -357,9 +358,11 @@ class Header(PatchNode): return (files[1] or files[0]).decode("UTF-8", errors="hexreplace") def __repr__(self) -> str: - return '<header %s>' % (' '.join( - repr(x) for x in self.files() - )) + return '<header %s>' % ( + ' '.join( + repr(x) for x in self.files() + ) + ) def special(self) -> bool: return any(self.special_re.match(h) for h in self.header) @@ -427,7 +430,17 @@ class Header(PatchNode): class HunkLine(PatchNode): """Represents a changed line in a hunk""" - def __init__(self, linetext: bytes, hunk): + DELETE = b'-' + INSERT = b'+' + CONTEXT = b' ' + HUNK = b'@' + NOEOL = b'\\' + + linetext: bytes + hunk: 'Hunk' + offset: int + + def __init__(self, linetext: bytes, hunk: 'Hunk'): self.linetext = linetext self.applied = True # the parent hunk to which this line belongs @@ -435,9 +448,11 @@ class HunkLine(PatchNode): # folding lines currently is not used/needed, but this flag is needed # in the prevItem method. self.folded = False + # at this moment, the offset of the line in the hunk isn’t known yet + self.offset = 0 def __bytes__(self): - if self.applied: + if self.applied or self.diffop == HunkLine.NOEOL: return self.linetext else: return b' ' + self.linetext[1:] @@ -446,6 +461,14 @@ class HunkLine(PatchNode): def diffop(self): return self.linetext[0:1] + def __repr__(self): + return "<hunkline/%c/%d %s %s>" % ( + self.linetext[0], + self.offset, + '[x]' if self.applied else '[ ]', + self.linetext[1:10], + ) + def __str__(self) -> str: return self.prettystr() @@ -462,7 +485,7 @@ class HunkLine(PatchNode): else: return None - def prevsibling(self): + def prevsibling(self) -> Optional['HunkLine']: """Return the previous line in the hunk""" indexofthisline = self.hunk.changedlines.index(self) if indexofthisline > 0: @@ -471,7 +494,7 @@ class HunkLine(PatchNode): else: return None - def parentitem(self): + def parentitem(self) -> 'Hunk': """Return the parent to the current item""" return self.hunk @@ -488,6 +511,7 @@ class HunkLine(PatchNode): class Hunk(PatchNode): """ui patch hunk, wraps a hunk and keeps track of ui behavior """ + maxcontext = 3 header: Header fromline: int @@ -498,17 +522,18 @@ class Hunk(PatchNode): changedlines: Sequence[HunkLine] def __init__( - self, - header: Header, - fromline: int, - toline: int, - proc: bytes, - before: Sequence[bytes], - hunklines: Sequence[bytes], - after: Sequence[bytes] + self, + header: Header, + fromline: int, + toline: int, + proc: bytes, + before: Sequence[bytes], + hunklines: Sequence[bytes], + after: Sequence[bytes], ): def trimcontext(number, lines): delta = len(lines) - self.maxcontext + # TODO: why is this disabled? if False and delta > 0: return number + delta, lines[:self.maxcontext] return number, lines @@ -519,6 +544,7 @@ class Hunk(PatchNode): self.proc = proc self.changedlines = [HunkLine(line, self) for line in hunklines] self.added, self.removed = self.countchanges() + self.countoffsets() # used at end for detecting how many removed lines were un-applied self.originalremoved = self.removed @@ -574,18 +600,40 @@ class Hunk(PatchNode): def countchanges(self) -> tuple[int, int]: """changedlines -> (n+,n-)""" - add = len([line for line in self.changedlines if line.applied - and line.diffop == b'+']) - rem = len([line for line in self.changedlines if line.applied - and line.diffop == b'-']) + add = len( + [line for line in self.changedlines if line.applied and line.diffop == HunkLine.INSERT], + ) + rem = len( + [line for line in self.changedlines if line.applied and line.diffop == HunkLine.DELETE], + ) return add, rem + def countoffsets(self): + fromline = 0 + toline = 0 + deletes = False + for line in self.changedlines: + if line.diffop == HunkLine.INSERT: + fromline += 1 + line.offset = fromline + if line.diffop == HunkLine.DELETE: + deletes = True + toline += 1 + line.offset = toline + if line.diffop == HunkLine.NOEOL: + if not deletes: + line.offset = fromtoline + else: + line.offset = toline + def getfromtoline(self): """Calculate the number of removed lines converted to context lines""" removedconvertedtocontext = self.originalremoved - self.removed - contextlen = (len(self.before) + len(self.after) + - removedconvertedtocontext) + contextlen = ( + len(self.before) + len(self.after) + + removedconvertedtocontext + ) if self.after and self.after[-1] == b'\\ No newline at end of file\n': contextlen -= 1 fromlen = contextlen + self.removed @@ -603,24 +651,45 @@ class Hunk(PatchNode): if tolen == 0 and toline > 0: toline -= 1 - fromtoline = b'@@ -%d,%d +%d,%d @@%b\n' % ( - fromline, fromlen, toline, tolen, - self.proc and (b' ' + self.proc)) + fromtoline = b"@@ -%d,%d +%d,%d @@%b\n" % ( + fromline, + fromlen, + toline, + tolen, + self.proc and (b" " + self.proc), + ) return fromtoline - def write(self, fp: IO[bytes]) -> None: + def iterlines(self, all: bool = False): # updated self.added/removed, which are used by getfromtoline() self.added, self.removed = self.countchanges() - fp.write(self.getfromtoline()) - fp.write(b''.join(self.before)) + yield HunkLine.HUNK, self.getfromtoline() + for line in self.before: + yield HunkLine.CONTEXT, line + + # Include these lines: + # (1) all applied lines + # (2) all unapplied removal lines (converted context lines) + # Lines are sorted by their offset in the original hunk, + # with removals coming first. + for hunkline in sorted( + self.changedlines, + key=lambda line: ( + line.offset, line.diffop == HunkLine.INSERT, + ), + ): + if hunkline.diffop == HunkLine.INSERT and not (hunkline.applied or all): + continue + + yield hunkline.diffop, bytes(hunkline) + + for line in self.after: + yield HunkLine.CONTEXT, line - # add the following to the list: (1) all applied lines, and - # (2) all unapplied removal lines (convert these to context lines) - for changedline in self.changedlines: - fp.write(bytes(changedline)) - - fp.write(b''.join(self.after)) + def write(self, fp: IO[bytes]) -> None: + for _, line in self.iterlines(): + fp.write(line) def reversehunks(self) -> 'Hunk': r"""Make the hunk apply in the other direction. @@ -642,10 +711,21 @@ class Hunk(PatchNode): 4 5 """ - m = {b'+': b'-', b'-': b'+', b'\\': b'\\'} - hunklines = [b'%s%s' % (m[line.linetext[0:1]], line.linetext[1:]) - for line in self.changedlines if line.applied] - return Hunk(self.header, self.fromline, self.toline, self.proc, self.before, hunklines, self.after) + m = {b"+": b"-", b"-": b"+", b"\\": b"\\"} + hunklines = [ + b"%s%s" % (m[line.linetext[0:1]], line.linetext[1:]) + for line in self.changedlines + if line.applied + ] + return Hunk( + self.header, + self.fromline, + self.toline, + self.proc, + self.before, + hunklines, + self.after, + ) def files(self) -> list[Optional[bytes]]: return self.header.files() @@ -743,6 +823,32 @@ def parsepatch(fp: IO[bytes]) -> PatchRoot: @@ -8,0 +10,1 @@ +9 + Parsing a patch changing the EOL at the EOF: + >>> rawpatch = b'''diff --git a/test b/test + ... --- a/test + ... +++ b/test + ... @@ -11,5 +11,5 @@ + ... foo + ... bar + ... baz + ... -qoox + ... -qooox + ... \\ No newline at end of file + ... +quux + ... +quuux''' + >>> fp = io.BytesIO(rawpatch) + >>> headers = parsepatch(fp) + >>> print(headers[0].hunks[0]) + @@ -11,5 +11,5 @@ + foo + bar + baz + -qoox + +quux + -qooox + \ No newline at end of file + +quuux + It is possible to handle non-UTF-8 patches: >>> rawpatch = b'''diff --git a/test b/test ... --- /dev/null @@ -826,8 +932,15 @@ def parsepatch(fp: IO[bytes]) -> PatchRoot: next hunk we parse. """ - h = Hunk(self.header, self.fromline, self.toline, self.proc, - self.before, self.hunk, self.context) + h = Hunk( + self.header, + self.fromline, + self.toline, + self.proc, + self.before, + self.hunk, + self.context, + ) self.header.hunks.append(h) self.headers.append(h) self.fromline += len(self.before) + h.removed + len(self.context) @@ -844,7 +957,7 @@ def parsepatch(fp: IO[bytes]) -> PatchRoot: Also, if an unprocessed set of changelines was previously encountered, this is the condition for creating a complete hunk object. In this case, we create and add a new hunk object to - the most recent header object, and to self.strem. + the most recent header object, and to self.strem. """ self.context = context @@ -872,7 +985,7 @@ def parsepatch(fp: IO[bytes]) -> PatchRoot: filename the header applies to. Add the header to self.headers. """ - # if there are any lines in the unchanged-lines buffer, create a + # if there are any lines in the unchanged-lines buffer, create a # new hunk using them, and add it to the last header. if self.hunk: self.add_new_hunk() @@ -883,7 +996,7 @@ def parsepatch(fp: IO[bytes]) -> PatchRoot: self.header = h def finished(self): - # if there are any lines in the unchanged-lines buffer, create a + # if there are any lines in the unchanged-lines buffer, create a # new hunk using them, and add it to the last header. if self.hunk: self.add_new_hunk() @@ -891,18 +1004,26 @@ def parsepatch(fp: IO[bytes]) -> PatchRoot: return self.headers transitions = { - 'file': {'context': addcontext, - 'file': newfile, - 'hunk': addhunk, - 'range': addrange}, - 'context': {'file': newfile, - 'hunk': addhunk, - 'range': addrange}, - 'hunk': {'context': addcontext, - 'file': newfile, - 'range': addrange}, - 'range': {'context': addcontext, - 'hunk': addhunk}, + 'file': { + 'context': addcontext, + 'file': newfile, + 'hunk': addhunk, + 'range': addrange, + }, + 'context': { + 'file': newfile, + 'hunk': addhunk, + 'range': addrange, + }, + 'hunk': { + 'context': addcontext, + 'file': newfile, + 'range': addrange, + }, + 'range': { + 'context': addcontext, + 'hunk': addhunk, + }, } p = Parser() @@ -913,8 +1034,10 @@ def parsepatch(fp: IO[bytes]) -> PatchRoot: try: p.transitions[state][newstate](p, data) except KeyError: - raise PatchError('unhandled transition: %s -> %s' % - (state, newstate)) + raise PatchError( + 'unhandled transition: %s -> %s' % + (state, newstate), + ) state = newstate return PatchRoot(p.finished()) @@ -922,7 +1045,8 @@ def parsepatch(fp: IO[bytes]) -> PatchRoot: def filterpatch(opts, patch: PatchRoot, chunkselector, ui): r"""Interactively filter patch chunks into applied-only chunks - >>> rawpatch = b'''diff --git a/dir/file.c b/dir/file.c + >>> rawpatch = b'''* Unmerged path .gitignore + ... diff --git a/dir/file.c b/dir/file.c ... index e548702cb275..28208f7ff2ac 100644 ... --- a/dir/file.c ... +++ b/dir/file.c @@ -945,15 +1069,22 @@ def filterpatch(opts, patch: PatchRoot, chunkselector, ui): ... 16 ... 17 ... ''' + >>> from functools import partial + >>> def hunk_selector(selections: Sequence[bool], opts, headers, ui): + ... for i, hunk in enumerate(headers[0].hunks): + ... hunk.applied = selections[i] + ... + >>> def line_selector(selections: Sequence[bool], opts, headers, ui): + ... for i, hunkline in enumerate(headers[0].hunks[0].changedlines): + ... hunkline.applied = selections[i] + ... >>> patch = parsepatch(io.BytesIO(rawpatch)) >>> patch [<header b'dir/file.c' b'dir/file.c'>, <hunk b'dir/file.c'@1684>, <hunk b'dir/file.c'@1692>] - >>> def selector(opts, headers, ui): - ... headers[0].hunks[0].applied = False - ... - >>> applied = filterpatch(None, patch, selector, None) + >>> selections = [False, True] + >>> applied = filterpatch(None, patch, partial(hunk_selector, selections), None) >>> applied [<header b'dir/file.c' b'dir/file.c'>, <hunk b'dir/file.c'@1692>] @@ -978,10 +1109,11 @@ def filterpatch(opts, patch: PatchRoot, chunkselector, ui): applied_hunks = PatchRoot([]) for header in patch.headers: - if (header.applied and - (header.special() or header.binary() or len([ - h for h in header.hunks if h.applied - ]) > 0)): + if header.applied and ( + header.special() + or header.binary() + or len([h for h in header.hunks if h.applied]) > 0 + ): applied_hunks.append(header) fixoffset = 0 for hunk in header.hunks: diff --git a/git_crecord/encoding.py b/git_crecord/encoding.py index d023ff1..a431ec0 100644 --- a/git_crecord/encoding.py +++ b/git_crecord/encoding.py @@ -11,7 +11,6 @@ import unicodedata - # How to treat ambiguous-width characters. Set to 'WFA' to treat as wide. wide = "WF" @@ -20,7 +19,5 @@ def ucolwidth(d: str) -> int: """Find the column width of a Unicode string for display""" eaw = getattr(unicodedata, 'east_asian_width', None) if eaw is not None: - return sum([eaw(c) in wide and 2 or 1 for c in d]) + return sum(eaw(c) in wide and 2 or 1 for c in d) return len(d) - - diff --git a/git_crecord/gitrepo.py b/git_crecord/gitrepo.py index 7b65988..316e8ac 100644 --- a/git_crecord/gitrepo.py +++ b/git_crecord/gitrepo.py @@ -24,11 +24,10 @@ class GitTree: self._tree = tree def __repr__(self): - return "%s(%r)" % (self.__class__.__name__, self._tree) + return f"{self.__class__.__name__}({self._tree!r})" def read(self): - util.system(['git', 'read-tree', '--reset', - self._tree], onerr=RuntimeError) + util.system(['git', 'read-tree', '--reset', self._tree], onerr=RuntimeError) class GitIndex: @@ -37,7 +36,7 @@ class GitIndex: self.indextree = None def __repr__(self): - return "%s(%r, %r)" % (self.__class__.__name__, self._filename, self.indextree) + return f"{self.__class__.__name__}({self._filename!r}, {self.indextree!r})" def commit(self) -> ObjectHash: return util.systemcall( @@ -60,24 +59,28 @@ class GitIndex: class GitRepo: def __init__(self, path: os.PathLike | str | None): try: - self.path = Path(util.systemcall( - ['git', 'rev-parse', '--show-toplevel'], - dir=path, - encoding="fs", - onerr=util.Abort - ).rstrip('\n')) - self._controldir = Path(util.systemcall( - ['git', 'rev-parse', '--git-dir'], - dir=path, - encoding="fs", - ).rstrip('\n')) + self.path = Path( + util.systemcall( + ['git', 'rev-parse', '--show-toplevel'], + dir=path, + encoding="fs", + onerr=util.Abort, + ).rstrip('\n'), + ) + self._controldir = Path( + util.systemcall( + ['git', 'rev-parse', '--git-dir'], + dir=path, + encoding="fs", + ).rstrip('\n'), + ) if not self._controldir.is_dir(): raise util.Abort except util.Abort: sys.exit(1) def __repr__(self): - return "%s(%s)" % (self.__class__.__name__, self.path) + return f"{self.__class__.__name__}({self.path})" @property def controldir(self) -> Path: diff --git a/git_crecord/main.py b/git_crecord/main.py index b069150..9d25212 100644 --- a/git_crecord/main.py +++ b/git_crecord/main.py @@ -7,14 +7,14 @@ # # SPDX-License-Identifier: GPL-2.0-or-later -from gettext import gettext as _ -from pathlib import Path -from typing import Optional - import argparse +import importlib.metadata import os import sys import tempfile +from gettext import gettext as _ +from pathlib import Path +from typing import Optional from . import crecord_core from .gitrepo import GitRepo @@ -25,7 +25,7 @@ class Config: def get(self, section, item, default=None) -> Optional[str]: try: return systemcall( - ['git', 'config', '--get', '%s.%s' % (section, item)], + ['git', 'config', '--get', f'{section}.{item}'], onerr=KeyError, encoding="UTF-8", ).rstrip('\n') @@ -67,11 +67,13 @@ class Ui: @property def editor(self) -> str: - return (os.environ.get("GIT_EDITOR") or - self.config.get("core", "editor") or - os.environ.get("VISUAL") or - os.environ.get("EDITOR") or - 'sensible-editor') + return ( + os.environ.get("GIT_EDITOR") + or self.config.get("core", "editor") + or os.environ.get("VISUAL") + or os.environ.get("EDITOR") + or "sensible-editor" + ) def edit(self, text: bytes, user, extra=None, name=None) -> bytes: f = tempfile.NamedTemporaryFile( @@ -86,8 +88,9 @@ class Ui: editor = self.editor - system("%s \"%s\"" % (editor, f.name), - onerr=Abort, errprefix=_("edit failed")) + system( + f'{editor} "{f.name}"', onerr=Abort, errprefix=_("edit failed"), + ) t = Path(f.name).read_bytes() @@ -99,44 +102,77 @@ class Ui: def stage(self, *files, **opts): to_add = [f for f in files if os.path.exists(f)] if to_add: - system(['git', 'add', '-f', '-N', '--'] + to_add, - onerr=Abort, errprefix=_("add failed")) + system( + ['git', 'add', '-f', '--'] + to_add, + onerr=Abort, + errprefix=_("add failed"), + ) def commit(self, *files, **opts): msgfile = self.repo.controldir / "CRECORD_COMMITMSG" try: args = [] - if opts['message']: - msgfile.write_text(opts['message']) + if opts["message"]: + msgfile.write_text(opts["message"]) - if opts['cleanup'] is None: - opts['cleanup'] = 'strip' + if opts["cleanup"] is None: + opts["cleanup"] = "strip" for k, v in opts.items(): - if k in ('author', 'date', 'amend', 'signoff', 'cleanup', - 'reset_author', 'gpg_sign', 'no_gpg_sign', - 'reedit_message', 'reuse_message', 'fixup', 'quiet'): + if k in ( + "author", + "date", + "amend", + "signoff", + "cleanup", + "reset_author", + "gpg_sign", + "no_gpg_sign", + "reedit_message", + "reuse_message", + "fixup", + "quiet", + ): if v is None: continue if isinstance(v, bool): if v is True: - args.append('--%s' % k.replace('_', '-')) + args.append("--%s" % k.replace("_", "-")) else: - args.append('--%s=%s' % (k.replace('_', '-'), v)) + args.append("--{}={}".format(k.replace("_", "-"), v)) to_add = [f for f in files if os.path.exists(f)] if to_add: - system(['git', 'add', '-f', '-N', '--'] + to_add, - onerr=Abort, errprefix=_("add failed")) - if not opts['message']: - system(['git', 'commit'] + args + ['--'] + list(files), - onerr=Abort, errprefix=_("commit failed")) + system( + ['git', 'add', '-f', '-N', '--'] + to_add, + onerr=Abort, + errprefix=_("add failed"), + ) + if not opts["message"]: + system( + ['git', 'commit'] + args + ['--'] + list(files), + onerr=Abort, + errprefix=_("commit failed"), + ) else: - system(['git', 'commit', '-F', msgfile.name] + args + ['--'] + list(files), - onerr=Abort, errprefix=_("commit failed")) + system( + ['git', 'commit', '-F', msgfile.name] + args + ['--'] + list(files), + onerr=Abort, + errprefix=_("commit failed"), + ) # refresh the index so that gitk doesn’t show empty staged diffs - systemcall(['git', 'update-index', '--ignore-submodules', '-q', '--ignore-missing', '--unmerged', '--refresh']) + systemcall( + [ + 'git', + 'update-index', + '--ignore-submodules', + '-q', + '--ignore-missing', + '--unmerged', + '--refresh', + ], + ) finally: msgfile.unlink(missing_ok=True) @@ -144,6 +180,7 @@ class Ui: def main(): prog = os.path.basename(sys.argv[0]).replace('-', ' ') + version = importlib.metadata.version("git-crecord") subcommand = prog.split(' ')[-1].replace('.py', '') @@ -158,19 +195,38 @@ def main(): parser.add_argument('--author', default=None, help='override author for commit') parser.add_argument('--date', default=None, help='override date for commit') parser.add_argument('-m', '--message', default=None, help='commit message') - parser.add_argument('-c', '--reedit-message', metavar='COMMIT', default=None, help='reuse and edit message from specified commit') - parser.add_argument('-C', '--reuse-message', metavar='COMMIT', default=None, help='reuse message from specified commit') - parser.add_argument('--fixup', metavar='COMMIT', default=None, help='create autosquash commit message to fixup specified commit') - parser.add_argument('--reset-author', action='store_true', default=False, help='the commit is authored by me now (used with -C/-c/--amend)') + parser.add_argument( + '-c', '--reedit-message', metavar='COMMIT', default=None, + help='reuse and edit message from specified commit', + ) + parser.add_argument( + '-C', '--reuse-message', metavar='COMMIT', default=None, + help='reuse message from specified commit', + ) + parser.add_argument( + '--fixup', metavar='COMMIT', default=None, + help='create autosquash commit message to fixup specified commit', + ) + parser.add_argument( + '--reset-author', action='store_true', default=False, + help='the commit is authored by me now (used with -C/-c/--amend)', + ) parser.add_argument('-s', '--signoff', action='store_true', default=False, help='add Signed-off-by:') parser.add_argument('--amend', action='store_true', default=False, help='amend previous commit') - parser.add_argument('-S', '--gpg-sign', metavar='KEY-ID', nargs='?', const=True, default=None, help='GPG sign commit') + parser.add_argument( + '-S', '--gpg-sign', metavar='KEY-ID', nargs='?', const=True, default=None, + help='GPG sign commit', + ) parser.add_argument('--no-gpg-sign', action='store_true', default=False, help=argparse.SUPPRESS) parser.add_argument('-v', '--verbose', default=0, action='count', help='be more verbose') parser.add_argument('--debug', action='store_const', const=2, dest='verbose', help='be debuggingly verbose') parser.add_argument('--cleanup', default=None, help=argparse.SUPPRESS) parser.add_argument('--quiet', default=False, action='store_true', help='pass --quiet to git commit') - parser.add_argument('--confirm', default=False, action='store_true', help='show confirmation prompt after selecting changes') + parser.add_argument( + '--confirm', default=False, action='store_true', + help='show confirmation prompt after selecting changes', + ) + parser.add_argument('--version', action='version', version=f'%(prog)s {version}') group = parser.add_mutually_exclusive_group() group.add_argument('--cached', '--staged', action='store_true', default=False, help=argparse.SUPPRESS) group.add_argument('--index', action='store_true', default=False, help=argparse.SUPPRESS) diff --git a/git_crecord/util.py b/git_crecord/util.py index 9cb9033..c99faf9 100644 --- a/git_crecord/util.py +++ b/git_crecord/util.py @@ -14,23 +14,25 @@ # SPDX-License-Identifier: GPL-2.0-or-later from __future__ import annotations -from gettext import gettext as _ import os -import subprocess import shutil +import subprocess import sys +from collections.abc import Sequence +from gettext import gettext as _ from pathlib import Path -from typing import AnyStr, overload, Sequence, Optional, Union +from typing import Optional, overload from .encoding import ucolwidth - closefds = os.name == 'posix' def explainexit(code): - """return a 2-tuple (desc, code) describing a subprocess status - (codes from kill are negative - not os.system/wait encoding)""" + """Return a 2-tuple (desc, code) describing a subprocess status. + + Codes from kill are negative - not os.system/wait encoding. + """ if (code < 0) and (os.name == 'posix'): return _("killed by signal %d") % -code, -code else: @@ -54,34 +56,32 @@ def system(cmd, cwd=None, onerr=None, errprefix=None): shell = True prog = os.path.basename(cmd.split(None, 1)[0]) - rc = subprocess.call(cmd, shell=shell, close_fds=closefds, - cwd=cwd) + rc = subprocess.call(cmd, shell=shell, close_fds=closefds, cwd=cwd) if rc and onerr: - errmsg = '%s %s' % (prog, - explainexit(rc)[0]) + errmsg = f"{prog} {explainexit(rc)[0]}" if errprefix: - errmsg = '%s: %s' % (errprefix, errmsg) + errmsg = f"{errprefix}: {errmsg}" raise onerr(errmsg) return rc @overload def systemcall( - cmd: Sequence[str] | Sequence[bytes], - encoding: str, - dir: Optional[os.PathLike | str] = None, - onerr=None, - errprefix=None + cmd: Sequence[str] | Sequence[bytes], + encoding: str, + dir: os.PathLike | str | None = None, + onerr=None, + errprefix=None, ) -> str: ... @overload def systemcall( - cmd: Sequence[str] | Sequence[bytes], - dir: Optional[os.PathLike | str] = None, - onerr=None, - errprefix=None + cmd: Sequence[str] | Sequence[bytes], + dir: os.PathLike | str | None = None, + onerr=None, + errprefix=None, ) -> bytes: ... @@ -89,7 +89,7 @@ def systemcall( def systemcall(cmd, encoding=None, dir=None, onerr=None, errprefix=None): try: sys.stdout.flush() - except Exception: + except Exception: # noqa: B902 pass p = subprocess.Popen(cmd, cwd=dir, stdout=subprocess.PIPE, close_fds=closefds) @@ -100,10 +100,9 @@ def systemcall(cmd, encoding=None, dir=None, onerr=None, errprefix=None): rc = p.returncode if rc and onerr: - errmsg = '%s %s' % (os.path.basename(cmd[0]), - explainexit(rc)[0]) + errmsg = f'{os.path.basename(cmd[0])} {explainexit(rc)[0]}' if errprefix: - errmsg = '%s: %s' % (errprefix, errmsg) + errmsg = f'{errprefix}: {errmsg}' raise onerr(errmsg) if encoding == "fs": @@ -114,7 +113,7 @@ def systemcall(cmd, encoding=None, dir=None, onerr=None, errprefix=None): return out -def copyfile(src: Union[str, Path], dest: Union[str, Path], copystat=True): +def copyfile(src: str | Path, dest: str | Path, copystat=True): """Copy a file, preserving mode and optionally other stat info like atime/mtime""" if os.path.lexists(dest): os.unlink(dest) @@ -145,41 +144,41 @@ def trim(s, width, ellipsis='', leftside=False): If 'leftside' is True, left side of string 's' is trimmed. 'ellipsis' is always placed at trimmed side. - >>> ellipsis = '+++' + >>> pluses = '+++' >>> encoding = 'utf-8' >>> t = '1234567890' - >>> print(trim(t, 12, ellipsis=ellipsis)) + >>> print(trim(t, 12, ellipsis=pluses)) 1234567890 - >>> print(trim(t, 10, ellipsis=ellipsis)) + >>> print(trim(t, 10, ellipsis=pluses)) 1234567890 - >>> print(trim(t, 8, ellipsis=ellipsis)) + >>> print(trim(t, 8, ellipsis=pluses)) 12345+++ - >>> print(trim(t, 8, ellipsis=ellipsis, leftside=True)) + >>> print(trim(t, 8, ellipsis=pluses, leftside=True)) +++67890 >>> print(trim(t, 8)) 12345678 >>> print(trim(t, 8, leftside=True)) 34567890 - >>> print(trim(t, 3, ellipsis=ellipsis)) + >>> print(trim(t, 3, ellipsis=pluses)) +++ - >>> print(trim(t, 1, ellipsis=ellipsis)) + >>> print(trim(t, 1, ellipsis=pluses)) + >>> t = '\u3042\u3044\u3046\u3048\u304a' # 2 x 5 = 10 columns - >>> print(trim(t, 12, ellipsis=ellipsis)) + >>> print(trim(t, 12, ellipsis=pluses)) \u3042\u3044\u3046\u3048\u304a - >>> print(trim(t, 10, ellipsis=ellipsis)) + >>> print(trim(t, 10, ellipsis=pluses)) \u3042\u3044\u3046\u3048\u304a - >>> print(trim(t, 8, ellipsis=ellipsis)) + >>> print(trim(t, 8, ellipsis=pluses)) \u3042\u3044+++ - >>> print(trim(t, 8, ellipsis=ellipsis, leftside=True)) + >>> print(trim(t, 8, ellipsis=pluses, leftside=True)) +++\u3048\u304a >>> print(trim(t, 5)) \u3042\u3044 >>> print(trim(t, 5, leftside=True)) \u3048\u304a - >>> print(trim(t, 4, ellipsis=ellipsis)) + >>> print(trim(t, 4, ellipsis=pluses)) +++ - >>> print(trim(t, 4, ellipsis=ellipsis, leftside=True)) + >>> print(trim(t, 4, ellipsis=pluses, leftside=True)) +++ """ if ucolwidth(s) <= width: # trimming is not needed @@ -189,12 +188,13 @@ def trim(s, width, ellipsis='', leftside=False): if width <= 0: # no enough room even for ellipsis return ellipsis[:width + len(ellipsis)] + # TODO: find a way to get rid of the lambdas if leftside: - uslice = lambda i: s[i:] - concat = lambda s: ellipsis + s + uslice = lambda i: s[i:] # noqa: E731 + concat = lambda s: ellipsis + s # noqa: E731 else: - uslice = lambda i: s[:-i] - concat = lambda s: s + ellipsis + uslice = lambda i: s[:-i] # noqa: E731 + concat = lambda s: s + ellipsis # noqa: E731 for i in range(1, len(s)): usub = uslice(i) if ucolwidth(usub) <= width: @@ -1,6 +1,6 @@ [metadata] name = git-crecord -version = 20220324.0 +version = 20230226.0 url = https://github.com/andrewshadura/git-crecord author = Andrej Shadura author-email = andrew@shadura.me @@ -31,6 +31,14 @@ tests_require = git-crecord[test] [options.extras_require] test = pytest >= 6 +lint = + flake8 + flake8-blind-except + flake8-builtins + flake8-commas + flake8-comprehensions + flake8-docstrings + flake8-isort [tool:pytest] addopts = --doctest-modules @@ -39,6 +47,23 @@ doctest_optionflags = NORMALIZE_WHITESPACE [options.entry_points] console_scripts = git-crecord = git_crecord.main:main + git-cstage = git_crecord.main:main + +[flake8] +doctests = yes +exclude = .*,build,dist,__pycache__ +max-line-length = 130 +ignore = + E261,E127,E128, + W503,W504, + D300,D400, + D100,D101,D102,D103,D104,D105,D107, + D205,D209, + +[isort] +multi_line_output = 3 +include_trailing_comma = yes +reverse_relative = yes [egg_info] tag_build = @@ -1,21 +1,30 @@ #!/usr/bin/env python3 -import os import fnmatch +import os from distutils import log -from setuptools import setup, find_packages + +from setuptools import setup +from setuptools.command import build_py, sdist + +__manpages__ = 'git-*.rst' + def read(fname): - return open(os.path.join(os.path.dirname(__file__), fname)).read() + with open(os.path.join(os.path.dirname(__file__), fname)) as f: + return f.read() + def glob(fname): return fnmatch.filter(os.listdir(os.path.abspath(os.path.dirname(__file__))), fname) + def generate_manpage(src, dst): import docutils.core - log.info("generating a manpage from %s to %s", src, dst) + log.info("generating a manpage from %s as %s", src, dst) docutils.core.publish_file(source_path=src, destination_path=dst, writer_name='manpage') + def man_name(fname): import re matches = re.compile(r'^:Manual section: *([0-9]*)', re.MULTILINE).search(read(fname)) @@ -27,40 +36,45 @@ def man_name(fname): manfname = base + '.' + section return manfname + def man_path(fname): category = fname.rsplit('.', 1)[1] return os.path.join('share', 'man', 'man' + category), [fname] + def man_files(pattern): - return list(map(man_path, map(man_name, glob(pattern)))) + return [man_path(man_name(f)) for f in glob(pattern)] + # monkey patch setuptools to use distutils owner/group functionality -from setuptools.command import sdist +# and build the manpage on build sdist_org = sdist.sdist +build_py_org = build_py.build_py + + class sdist_new(sdist_org): def initialize_options(self): sdist_org.initialize_options(self) self.owner = self.group = 'root' -sdist.sdist = sdist_new -__manpages__ = 'git-*.rst' -from setuptools.command import build_py -build_py_org = build_py.build_py class build_py_new(build_py_org): def run(self): build_py_org.run(self) if not self.dry_run: for page in glob(__manpages__): generate_manpage(page, man_name(page)) -build_py.build_py = build_py_new + + +sdist.sdist = sdist_new # type: ignore +build_py.build_py = build_py_new # type: ignore __name__ = "git-crecord" setup( - data_files = [ + data_files=[ (os.path.join('share', 'doc', __name__), glob('*.rst')), (os.path.join('share', 'doc', __name__), glob('*.png')), - (os.path.join('share', 'doc', __name__), ['CONTRIBUTORS', 'COPYING']) - ] + man_files(__manpages__) + (os.path.join('share', 'doc', __name__), ['CONTRIBUTORS', 'COPYING']), + ] + man_files(__manpages__), ) diff --git a/tests/test_hunk_splitting.py b/tests/test_hunk_splitting.py new file mode 100644 index 0000000..e9a4e23 --- /dev/null +++ b/tests/test_hunk_splitting.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import io +from collections.abc import Sequence +from functools import partial +from textwrap import dedent + +import pytest + +from git_crecord.crpatch import filterpatch, parsepatch + + +def line_selector(selections: Sequence[bool], opts, headers, ui): + for i, hunkline in enumerate(headers[0].hunks[0].changedlines): + hunkline.applied = selections[i] + + +@pytest.mark.parametrize( + ("selections", "expected"), + [ + pytest.param( + [True, False, True, False], + ''' + @@ -1,3 +1,3 @@ + RUN apt-get update + - && apt-get install -y supervisor python3.8 + + && apt-get install -y supervisor python3.9 + git python3-pip ssl-cert + ''', + id="symmetric", + ), + pytest.param( + [True, True, True, False], + ''' + @@ -1,3 +1,2 @@ + RUN apt-get update + - && apt-get install -y supervisor python3.8 + + && apt-get install -y supervisor python3.9 + - git python3-pip ssl-cert + ''', + id="extra deletion", + ), + pytest.param( + [True, False, True, True], + ''' + @@ -1,3 +1,4 @@ + RUN apt-get update + - && apt-get install -y supervisor python3.8 + + && apt-get install -y supervisor python3.9 + git python3-pip ssl-cert + + git python3-pip ssl-cert time + ''', + id="extra addition", + ), + ], +) +def test_hunk_splitting(selections: Sequence[bool], expected: str): + diff = dedent( + ''' + diff --git a/Dockerfile b/Dockerfile + index 00083f466d4f..400252a22712 100644 + --- a/Dockerfile + +++ b/Dockerfile + @@ -1,3 +1,3 @@ + RUN apt-get update + - && apt-get install -y supervisor python3.8 + - git python3-pip ssl-cert + + && apt-get install -y supervisor python3.9 + + git python3-pip ssl-cert time + ''', + ).lstrip('\n').encode() + patch = parsepatch(io.BytesIO(diff)) + applied = filterpatch(None, patch, partial(line_selector, selections), None) + + assert str(applied.hunks[0]) == dedent(expected).lstrip('\n') |