summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAndrej Shadura <andrewsh@debian.org>2023-02-26 22:47:27 +0100
committerAndrej Shadura <andrewsh@debian.org>2023-02-26 22:47:27 +0100
commit55fb3bb8b5ada108958e0133f42b5b371afbef3c (patch)
tree4445864dce508f7a486a407a989b2c1a02186c8c
parentb45961d0e98857b174681a27b2bf9c2abfba7a96 (diff)
parentef229d8ef0f468441954318afdada1c7c8b9f0c7 (diff)
New upstream version 20230226.0
-rw-r--r--PKG-INFO6
-rw-r--r--git-crecord.rst2
-rw-r--r--git_crecord.egg-info/PKG-INFO6
-rw-r--r--git_crecord.egg-info/SOURCES.txt3
-rw-r--r--git_crecord.egg-info/entry_points.txt2
-rw-r--r--git_crecord.egg-info/requires.txt9
-rw-r--r--git_crecord/chunk_selector.py379
-rw-r--r--git_crecord/crecord_core.py67
-rw-r--r--git_crecord/crpatch.py298
-rw-r--r--git_crecord/encoding.py5
-rw-r--r--git_crecord/gitrepo.py35
-rw-r--r--git_crecord/main.py130
-rw-r--r--git_crecord/util.py84
-rw-r--r--setup.cfg27
-rwxr-xr-xsetup.py42
-rw-r--r--tests/test_hunk_splitting.py75
16 files changed, 751 insertions, 419 deletions
diff --git a/PKG-INFO b/PKG-INFO
index f9d920b..9563d56 100644
--- a/PKG-INFO
+++ b/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.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:
diff --git a/setup.cfg b/setup.cfg
index 030ba17..cc7f521 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -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 =
diff --git a/setup.py b/setup.py
index 64ffb15..b9c751a 100755
--- a/setup.py
+++ b/setup.py
@@ -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')