From 3404a95882e0cdd1d6255ca2eda4bc19a844378b Mon Sep 17 00:00:00 2001 From: zPlus Date: Sat, 13 Aug 2022 13:59:56 +0200 Subject: [PATCH] Show diff between arbitrary commits. Allow a commit to be diffed with any arbitrary commit (for example great-grandparent) instead of just its first parent. This commit also adds the options for downloading .diff and .patch files for a commit. --- static/css/clif.css | 46 ++- templates/repository/commit.html | 477 ++++++++++++++++--------------- web.py | 55 +++- 3 files changed, 333 insertions(+), 245 deletions(-) diff --git a/static/css/clif.css b/static/css/clif.css index 5cbebc0..c31ea7a 100644 --- a/static/css/clif.css +++ b/static/css/clif.css @@ -44,6 +44,28 @@ pre { line-height: 1.5rem; } +.separator { + align-items: center; + display: flex; + margin: 1rem 0; + text-align: center; +} + + .separator::before, + .separator::after { + border-bottom: 1px solid #aaa; + content: ''; + flex: 1; + } + + .separator:not(:empty)::before { + margin-right: 1rem; + } + + .separator:not(:empty)::after { + margin-left: 1rem; + } + .top_bar { display: flex; width: 100%; @@ -329,6 +351,18 @@ div.commit { white-space: pre-wrap; } + div.commit .accumulated { + margin-top: .5rem; + } + + div.commit .accumulated .insertions { + color: darkgreen; + } + + div.commit .accumulated .deletions { + color: darkred; + } + div.commit details.diffstat { } @@ -372,18 +406,6 @@ div.commit { background: red; } - div.commit details.diffstat .accumulated { - margin-top: .5rem; - } - - div.commit details.diffstat .accumulated .insertions { - color: darkgreen; - } - - div.commit details.diffstat .accumulated .deletions { - color: darkred; - } - div.commit details.diff_options { margin-bottom: 2rem; } diff --git a/templates/repository/commit.html b/templates/repository/commit.html index 56667ce..a917fc1 100644 --- a/templates/repository/commit.html +++ b/templates/repository/commit.html @@ -30,7 +30,8 @@ Commit - {{ commit.id }} + {{ commit.short_id }} + (patch) @@ -38,7 +39,7 @@ Tree - {{ commit.tree.id }} + {{ commit.tree.short_id }} @@ -47,7 +48,10 @@ {% for parent in commit.parents %} - {{ parent.short_id }} +
+ {{ parent.short_id }} + (diff) +
{% endfor %} @@ -58,14 +62,33 @@
{{ commit.message }}
-



+

-
- {# pygit2 appears to recompute all the stats every time we use diff.stats, - # therefore we set this variable in order to compute it only once. - #} - {% set diff_stats = diff.stats %} +
+ commits diff: + {{ commit2.short_id if commit2 else '0000000' }}..{{ commit.short_id }} +
+ + {# pygit2 appears to recompute all the stats every time we use diff.stats, + # therefore we set this variable in order to compute it only once. + #} + {% set diff_stats = diff.stats %} + +
+ {{ diff_stats.files_changed }} file{{ 's' if diff_stats.files_changed != 1 }} changed, + {{ diff_stats.insertions }} insertion{{ 's' if diff_stats.insertions != 1 }}, + {{ diff_stats.deletions }} deletion{{ 's' if diff_stats.deletions != 1 }} + — + {% if commit2 %} + download + {% else %} + download + {% endif %} +
+ +

+
Diffstat {% for patch in diff %} @@ -85,12 +108,6 @@ {% endfor %}
- -
- {{ diff_stats.files_changed }} file{{ 's' if diff_stats.files_changed != 1 }} changed, - {{ diff_stats.insertions }} insertion{{ 's' if diff_stats.insertions != 1 }}, - {{ diff_stats.deletions }} deletion{{ 's' if diff_stats.deletions != 1 }} -

@@ -111,38 +128,38 @@ - Context lines + Side - + + - Inter-hunk lines + Whitespace - + + + + - Side + Context lines - - + - Whitespace + Inter-hunk lines - - - - + @@ -151,225 +168,235 @@
{% if mode == 'udiff_raw' %} - +
{{ diff.patch|highlight_udiff|safe }}
{% else %} - - {% for patch in diff %} - - {# The following status values are defined in the git_delta_t enum - # in libgit2. See https://github.com/libgit2/libgit2/blob/main/include/git2/diff.h - # Looks like pygit2 also has a pygit2.DiffDelta.status_char() functions that - # returns the single-char abbreviation of the delta status, so we can use this - # instead of the raw integer. - # 0 = UNCHANGED - # 1 = ADDED (does not exist in old version) - # 2 = DELETED (does not exist in new version) - # 3 = MODIFIED (content changed between old and new versions) - # 4 = RENAMED - # 5 = COPIED - # ... (there are other codes that we don't use) - #} - {% set delta_char = patch.delta.status_char() %} - -
- - - {% if patch.line_stats[1] + patch.line_stats[2] > 0 %} - {% set color_border = patch.line_stats[1] / ( patch.line_stats[1] + patch.line_stats[2] ) * 100 %} - {% else %} - {% set color_border = 0 %} - {% endif %} - - - +{{ patch.line_stats[1] }}/-{{ patch.line_stats[2] }} - - - {% if delta_char == 'A' %} - A   {{ patch.delta.new_file.path }} - {% endif %} - - {% if delta_char == 'D' %} - D   {{ patch.delta.old_file.path }} - {% endif %} - - {% if delta_char == 'M' %} - M   {{ patch.delta.new_file.path }} - {% endif %} - - {% if delta_char == 'R' %} - R   {{ patch.delta.old_file.path }} -> {{ patch.delta.new_file.path }} - {% endif %} - - {% if delta_char == 'C' %} - C   {{ patch.delta.old_file.path }} -> {{ patch.delta.new_file.path }} - {% endif %} - - - - - - - - - - - -
- index {{ (patch.delta.old_file.id|string)[:7] }}..{{ (patch.delta.new_file.id|string)[:7] }} -
- old size: {{ patch.delta.old_file.size|human_size(B=true) }} - - - new size: {{ patch.delta.new_file.size|human_size(B=true) }} -
- {% if delta_char == 'A' %} - new file mode: {{ patch.delta.new_file.mode|filemode }} - {% elif delta_char == 'D' %} - deleted file mode: {{ patch.delta.old_file.mode|filemode }} - {% elif patch.delta.old_file.mode != patch.delta.new_file.mode %} - old mode: {{ patch.delta.old_file.mode|filemode }} -
- new mode: {{ patch.delta.new_file.mode|filemode }} - {% endif %} -
-
- - - {% if patch.delta.is_binary %} - - - - {% endif %} - - {% for hunk in patch.hunks if not patch.delta.is_binary %} + {% for patch in diff %} + + {# The following status values are defined in the git_delta_t enum + # in libgit2. See https://github.com/libgit2/libgit2/blob/main/include/git2/diff.h + # Looks like pygit2 also has a pygit2.DiffDelta.status_char() functions that + # returns the single-char abbreviation of the delta status, so we can use this + # instead of the raw integer. + # 0 = UNCHANGED + # 1 = ADDED (does not exist in old version) + # 2 = DELETED (does not exist in new version) + # 3 = MODIFIED (content changed between old and new versions) + # 4 = RENAMED + # 5 = COPIED + # ... (there are other codes that we don't use) + #} + + {% set delta_char = patch.delta.status_char() %} + +
+ + + {% if patch.line_stats[1] + patch.line_stats[2] > 0 %} + {% set color_border = patch.line_stats[1] / ( patch.line_stats[1] + patch.line_stats[2] ) * 100 %} + {% else %} + {% set color_border = 0 %} + {% endif %} - {#### UDIFF mode ####} - {# In this mode the lines are printed one after the other. #} + + +{{ patch.line_stats[1] }}/-{{ patch.line_stats[2] }} + - {% if mode == 'udiff' %} -
- - - - - - - {% for line in hunk.lines %} - - - - - - - {% endfor %} + {% if delta_char == 'A' %} + A   {{ patch.delta.new_file.path }} {% endif %} - {#### SSDIFF mode ####} - {# In this mode, changed lines are buffered until we find an - # unchanged line. When an unchanged line has been found, the - # buffer is emptied. - #} + {% if delta_char == 'D' %} + D   {{ patch.delta.old_file.path }} + {% endif %} - {% if mode == 'ssdiff' %} - {% set buffer_deletions = [] %} - {% set buffer_insertions = [] %} - - {% macro print_buffer(buffer_deletions, buffer_insertions) %} - {% for buffer_del, buffer_ins in zip_longest(buffer_deletions, buffer_insertions) %} - - {% if buffer_del %} - - - {% else %} - - - {% endif %} - - {% if buffer_ins %} - - - {% else %} - - - {% endif %} - - {% endfor %} - - {# .clear() empties the buffer. Since this requires {{}} - # brackets instead of {%%}, this line will print the value - # of "buffer_deletions" which is "None". - # "or ''" is a hack for printing an empty line instead of "None". - #} - {{ buffer_deletions.clear() or '' }} - {{ buffer_insertions.clear() or '' }} - {% endmacro %} - - - + {% if delta_char == 'M' %} + M   {{ patch.delta.new_file.path }} + {% endif %} + + {% if delta_char == 'R' %} + R   {{ patch.delta.old_file.path }} -> {{ patch.delta.new_file.path }} + {% endif %} + + {% if delta_char == 'C' %} + C   {{ patch.delta.old_file.path }} -> {{ patch.delta.new_file.path }} + {% endif %} + +
- Binary file -
{{ hunk.header }}
- {% if line.old_lineno >= 0 %} - {{ line.old_lineno }} - {% endif %} - - {% if line.new_lineno >= 0 %} - {{ line.new_lineno }} - {% endif %} - {{ line.origin }}{{ line.content }}
- {{ buffer_del.old_lineno }} - {{ buffer_del.content }} - - {{ buffer_ins.new_lineno }} - {{ buffer_ins.content }} -
{{ hunk.header }}
+ + + + + + + + +
+ index + {% if delta_char == 'A' %} + {{ (patch.delta.old_file.id|string)[:7] }} + {%- else -%} + {{ (patch.delta.old_file.id|string)[:7] }} + {%- endif -%} + .. + {%- if delta_char == 'D' -%} + {{ (patch.delta.new_file.id|string)[:7] }} + {%- else -%} + {{ (patch.delta.new_file.id|string)[:7] }} + {% endif %} +
+ old size: {{ patch.delta.old_file.size|human_size(B=true) }} + - + new size: {{ patch.delta.new_file.size|human_size(B=true) }} +
+ {% if delta_char == 'A' %} + new file mode: {{ patch.delta.new_file.mode|filemode }} + {% elif delta_char == 'D' %} + deleted file mode: {{ patch.delta.old_file.mode|filemode }} + {% elif patch.delta.old_file.mode != patch.delta.new_file.mode %} + old mode: {{ patch.delta.old_file.mode|filemode }} +
+ new mode: {{ patch.delta.new_file.mode|filemode }} + {% endif %} +
+ + + + {% if patch.delta.is_binary %} + + + + {% endif %} + + {% for hunk in patch.hunks if not patch.delta.is_binary %} - {% for line in hunk.lines %} - {% if line.old_lineno < 0 %} - {{ buffer_insertions.append(line) or '' }} - {% endif %} - - {% if line.new_lineno < 0 %} - {{ buffer_deletions.append(line) or '' }} - {% endif %} - - {% if line.old_lineno >= 0 and line.new_lineno >= 0 %} + {#### UDIFF mode ####} + {# In this mode the lines are printed one after the other. #} + + {% if mode == 'udiff' %} + + + + + + - {# Unload buffer #} - {{ print_buffer(buffer_deletions, buffer_insertions) }} - - {# Insert the unchanged line. #} - + {% for line in hunk.lines %} + - - + - {% endif %} - {% endfor %} + {% endfor %} + {% endif %} - {# Empty remaining buffer. There is a non-empty buffer if we did - # not find an unchanged line, for example when context_lines=0. + {#### SSDIFF mode ####} + {# In this mode, changed lines are buffered until we find an + # unchanged line. When an unchanged line has been found, the + # buffer is emptied. #} - {{ print_buffer(buffer_deletions, buffer_insertions) }} - {% endif %} - - {% endfor %} - -
+ Binary file +
{{ hunk.header }}
- {{ line.old_lineno }} + {% if line.old_lineno >= 0 %} + {{ line.old_lineno }} + {% endif %} {{ line.content }} - {{ line.new_lineno }} + {% if line.new_lineno >= 0 %} + {{ line.new_lineno }} + {% endif %} {{ line.origin }} {{ line.content }}
-
+ + {% if mode == 'ssdiff' %} + {% set buffer_deletions = [] %} + {% set buffer_insertions = [] %} + + {% macro print_buffer(buffer_deletions, buffer_insertions) %} + {% for buffer_del, buffer_ins in zip_longest(buffer_deletions, buffer_insertions) %} + + {% if buffer_del %} + + {{ buffer_del.old_lineno }} + + {{ buffer_del.content }} + {% else %} + + + + {% endif %} + + {% if buffer_ins %} + + {{ buffer_ins.new_lineno }} + + {{ buffer_ins.content }} + {% else %} + + + + {% endif %} + + {% endfor %} + + {# .clear() empties the buffer. Since this requires {{}} + # brackets instead of {%%}, this line will print the value + # of "buffer_deletions" which is "None". + # "or ''" is a hack for printing an empty line instead of "None". + #} + {{ buffer_deletions.clear() or '' }} + {{ buffer_insertions.clear() or '' }} + {% endmacro %} + + + {{ hunk.header }} + + + {% for line in hunk.lines %} + {% if line.old_lineno < 0 %} + {{ buffer_insertions.append(line) or '' }} + {% endif %} + + {% if line.new_lineno < 0 %} + {{ buffer_deletions.append(line) or '' }} + {% endif %} + + {% if line.old_lineno >= 0 and line.new_lineno >= 0 %} + + {# Unload buffer #} + {{ print_buffer(buffer_deletions, buffer_insertions) }} + + {# Insert the unchanged line. #} + + + {{ line.old_lineno }} + + {{ line.content }} + + + {{ line.new_lineno }} + + {{ line.content }} + + {% endif %} + {% endfor %} + + {# Empty remaining buffer. There is a non-empty buffer if we did + # not find an unchanged line, for example when context_lines=0. + #} + {{ print_buffer(buffer_deletions, buffer_insertions) }} + {% endif %} + + {% endfor %} + + + + +
-
- - {% endfor %} - + {% endfor %} {% endif %} diff --git a/web.py b/web.py index 487dbdf..823b44e 100644 --- a/web.py +++ b/web.py @@ -546,7 +546,11 @@ def log_change(repository): revision=revision)) @bottle.get('/.git/commit/', name='commit') -def commit(repository, commit_id): +@bottle.get('/.git/commit/..', name='commit2') +@bottle.get('/.git/commit/.patch', name='commit_patch') +@bottle.get('/.git/commit/.diff', name='commit_diff') +@bottle.get('/.git/commit/...diff', name='commit_diff2') +def commit(repository, commit_id, commit_id2=None): """ Show a commit. """ @@ -562,6 +566,15 @@ def commit(repository, commit_id): try: commit = repo.get(commit_id) assert commit.type == pygit2.GIT_OBJ_COMMIT + + if commit_id2: + commit2 = repo.get(commit_id2) + assert commit2.type == pygit2.GIT_OBJ_COMMIT + elif len(commit.parents) > 0: + commit2 = commit.parents[0] + assert commit2.type == pygit2.GIT_OBJ_COMMIT + else: + commit2 = None except: bottle.abort(404, 'Not a valid commit.') @@ -609,13 +622,12 @@ def commit(repository, commit_id): # Compute diff with parent - if len(commit.parents) > 0: - diff = commit.parents[0].tree.diff_to_tree( - commit.tree, - context_lines = diff_context_lines, - interhunk_lines = diff_inter_hunk_lines, - flags = diff_flags) + if commit2: + diff = repo.diff( + a=commit2, b=commit, flags=diff_flags, + context_lines=diff_context_lines, interhunk_lines=diff_inter_hunk_lines) else: + # diff with empty tree diff = commit.tree.diff_to_tree( context_lines = diff_context_lines, interhunk_lines = diff_inter_hunk_lines, @@ -625,9 +637,36 @@ def commit(repository, commit_id): # Compute the similarity index. This is used to decide which files are "renamed". diff.find_similar() + if request.route.name in [ 'commit_diff', 'commit_diff2' ]: + response.content_type = 'text/plain' + return diff.patch + + if request.route.name == 'commit_patch': + """ + Looks like pygit2 doesn't have a way for creating a patch file from a commit. + So, exclusively for this task, we fork a new subprocess and read the output. + TODO see if this subprocess call can be replaced with pygit2. + """ + + try: + output = subprocess.check_output( + args=[ 'git', 'format-patch', + '--no-signature', + '--stdout', + '--unified={}'.format(diff_context_lines), + '--inter-hunk-context={}'.format(diff_inter_hunk_lines), + '-1', str(commit.id) ], + cwd=repository_path) + + response.content_type = 'text/plain' + return output + except Exception as e: + print(e) + bottle.abort(500, 'Cannot create patch.') + return template( 'repository/commit.html', - repository=repository, commit=commit, diff=diff, + repository=repository, commit=commit, commit2=commit2, diff=diff, context_lines=diff_context_lines, inter_hunk_lines=diff_inter_hunk_lines, mode=diff_mode, side=diff_side, whitespace=diff_whitespace)