Skip to content

Instantly share code, notes, and snippets.

@mnieber
Last active May 18, 2019 10:43
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save mnieber/b88a2f3136aa1f733d96314507935c8d to your computer and use it in GitHub Desktop.
Save mnieber/b88a2f3136aa1f733d96314507935c8d to your computer and use it in GitHub Desktop.
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# Usage: git fixdown [-vfd]
#
# Finds all commits that are corrected by the staged changes. If there is only one
# such commit, makes a fixup commit targetting that commit.
#
# Note: place this script on the path, so git can find it,
# and make it executable (chmod +x git-fixdown).
#
# Author: Maarten Nieber
# Url: https://gist.github.com/mnieber/b88a2f3136aa1f733d96314507935c8d
#
# Based on git-autofixup (https://github.com/chrisarcand),
# who based it on work from @mislav
import argparse
import re
import os
import subprocess
import sys
regex_parts = r"^\-\-\-\ (.+)$"
regex_line_mutations = r"^\@\@\s\-([\,\w\d]+)"
def git(*args):
pipes = subprocess.Popen(['git'] + list(args),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
std_out, std_err = pipes.communicate()
if pipes.returncode != 0:
print(std_out)
print(std_err.strip())
sys.exit(pipes.returncode)
return std_out.decode('utf-8')
def staged_changes():
return git('diff', '--no-prefix', '--cached', '-U0')
def parts(staged_changes):
parts = list(re.finditer(regex_parts, staged_changes, re.MULTILINE))
result = dict()
for idx, match in enumerate(parts):
filename = match.group(1)
last_char = (parts[idx + 1].span()[0]
if idx + 1 < len(parts) else len(staged_changes))
result[filename] = staged_changes[match.span()[1]:last_char]
return result
def commit_timestamp(sha):
timestamp = git('show', '-s', '--format=%ct', sha)
return int(timestamp)
def original_lines(filename):
if filename == '/dev/null':
return []
result = git('show', 'HEAD:%s' % filename)
return result.split('\n')
def shas(parts):
result = set()
sha_2_changes = dict()
for filename, part in parts.items():
# If file was deleted in the staged changes...
if not os.path.exists(filename):
# TODO: maybe we should use the set of all previous shas that
# produced the latest version of the file
# for sha in git('log', '--pretty=format:%H', '--', filename):
# result.add(sha)
continue
lines = original_lines(filename)
matches = re.finditer(regex_line_mutations, part, re.MULTILINE)
for matchNum, match in enumerate(matches):
match = match.group(1)
parts = match.split(",")
start_line = int(parts[0])
nr_lines_removed = (1 if len(parts) == 1 else int(parts[1]))
for line in range(start_line, start_line + nr_lines_removed):
line_idx = line - 1
blame = git('blame', 'HEAD', filename, '-L', '%d,+1' % line)
blame = blame[1:] if blame[:1] == '^' else blame
sha = blame.split()[0]
changes = sha_2_changes.setdefault(sha, [])
changes.append((filename, line, lines[line_idx]))
result.add(sha)
sha_2_timestamp = {sha: commit_timestamp(sha) for sha in result}
sha_list = sorted(list(result), key=lambda sha: sha_2_timestamp[sha])
return sha_list, sha_2_changes
def commit_message(sha):
return git('show', '-s', '--oneline', sha)[:-1]
def print_changed_lines(changes):
for change in changes:
prefix = ("%s:%d" % change[0:2]).ljust(30)
print("%s %s" % (prefix, change[2]))
def bordered(text):
lines = text.decode('ascii', 'ignore').encode('ascii').splitlines()
width = max(len(s) for s in lines)
res = ['┌' + '─' * width + '┐']
for s in lines:
res.append('│' + (s + ' ' * width)[:width] + '│')
res.append('└' + '─' * width + '┘')
return '\n'.join(res)
if __name__ == '__main__':
parser = argparse.ArgumentParser()
group = parser.add_mutually_exclusive_group()
group.add_argument(
"-v",
"--verbose",
action="store_true",
help=("Print extra information about changed lines in case "
"there are multiple possible target commits"))
group.add_argument(
"-f",
"--force",
action="store_true",
help=("Force the creation of a fixup when there are multiple "
"possible target commits (pick the most recent commit)"))
group.add_argument("-d",
"--dry-run",
action="store_true",
help=("Don't create any fixup commit"))
args = parser.parse_args()
shas, sha_2_changes = shas(parts(staged_changes()))
if len(shas) == 0:
print("No changed lines")
elif len(shas) == 1 or args.force:
print(commit_message(shas[-1]))
if not args.dry_run:
git('commit', '--fixup', shas[-1])
else:
print("There are multiple possible target commits:")
for sha in shas:
if args.verbose:
print(bordered(commit_message(sha)))
print_changed_lines(sha_2_changes[sha])
print("")
else:
print(commit_message(sha))
sys.exit(1)
@asfaltboy
Copy link

@mnieber did you end up using this in Sublime Text, with a GitSavvy custom command? If so, care to share the definition ?

@mnieber
Copy link
Author

mnieber commented Feb 23, 2018

Hi @asfaltboy, yes, I created a GitSavvy custom command (~/.config/sublime-text-3/Packages/User/User.sublime-commands)

[
{
"caption": "git: fixdown",
"command": "gs_custom",
"args": {
"output_to_panel": true,
"args": ["fixdown"],
}
}
]

Is this what you meant?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment