Last active
March 28, 2023 16:16
-
-
Save tyrcho/3f15273497ff9d5b5a0b12c672094387 to your computer and use it in GitHub Desktop.
Script to accept Gitlab MRs when the pipeline has succeeded. Sample : https://gitlab.com/tyrcho/michel-steward/-/blob/master/.gitlab-ci.yml
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
# | |
# git-subline-merge | |
# | |
# Created by Paul Altin on 02.03.18. | |
# Downloaded from https://github.com/paulaltin/git-subline-merge | |
# | |
# An interactive git merge driver which can resolve non-overlapping conflicts on individual or adjacent lines. | |
# | |
# To install for use during merge/rebase, place this script somewhere on your path, add these lines to your ~/.gitconfig: | |
# | |
# [merge "git-subline-merge"] | |
# name = An interactive merge driver for resolving sub-line conflicts | |
# driver = git-subline-merge %O %A %B %L %P | |
# recursive = binary | |
# | |
# and this to your git attributes file (e.g. ~/.config/git/attributes): | |
# | |
# * merge=git-subline-merge | |
# | |
# Alternatively, run this script on a conflicted file using 'git-subline-merge /path/to/file' | |
# | |
import os, sys, re, tempfile | |
from subprocess import call | |
from shutil import copyfile | |
from builtins import input | |
# improved command line editing on *nix | |
try: | |
import readline | |
except ImportError: | |
pass | |
# for interpreting environment variables | |
try: | |
from distutils.util import strtobool | |
except ImportError: | |
from distutils import strtobool | |
############# | |
### SETUP ### | |
############# | |
WINDOWS = os.name == 'nt' | |
# max hunk sizes | |
MAX_HUNK_SIZE = int(os.getenv('GIT_SUBLINE_MERGE_MAX_HUNK_SIZE', 16)) | |
MAX_HUNK_SIZE_DIFF = int(os.getenv('GIT_SUBLINE_MERGE_MAX_HUNK_SIZE_DIFF', 8)) | |
# colors | |
# don't use color if stdout is not a terminal or if it doesn't support at least 8-bit color | |
# also respect NO_COLOR informal standard | |
stdout_isatty = hasattr(sys.stdout, 'isatty') and sys.stdout.isatty() | |
term_colors = int(os.popen('which tput >/dev/null && tput colors').read() or 0) | |
no_color = os.getenv('NO_COLOR') is not None | |
show_color = stdout_isatty and term_colors >= 8 and not no_color | |
# use colorama if available for colored terminal output on Windows | |
if WINDOWS and show_color: | |
try: | |
from colorama import init | |
init() | |
except ImportError: | |
print('Install colorama (pip install colorama) for colored output on Windows.') | |
pass | |
# interactive or non-interactive mode | |
stdin_isatty = hasattr(sys.stdin, 'isatty') and sys.stdin.isatty() | |
interactive = not bool(strtobool(os.getenv('GIT_SUBLINE_MERGE_NON_INTERACTIVE_MODE', 'False'))) | |
if interactive and not stdin_isatty and not WINDOWS: | |
try: | |
sys.stdin = open('/dev/tty') | |
except IOError: | |
print('Warning: git-subline-merge could not run in interactive mode because there is no controlling terminal.\n' | |
'You can enable non-interactive mode using the GIT_SUBLINE_MERGE_NON_INTERACTIVE_MODE environment variable,\n' | |
' however note that in this mode you will NOT be able to review merges before they are written to file.') | |
sys.exit(1) | |
############### | |
### HELPERS ### | |
############### | |
# for colored output in terminal | |
class color: | |
bold = '\033[1m' if show_color else '' | |
welcome = '\033[1m\033[91m' if show_color else '' # bold, red | |
info = '\033[96m' if show_color else '' # cyan | |
highlight = '\033[103m\033[1m\033[30m' if show_color else '' # bold, black, yellow bg | |
added1 = '\033[48;5;28m' if show_color else '' # bright green | |
added2 = '\033[48;5;22m' if show_color else '' # dark green | |
deleted1 = '\033[48;5;124m' if show_color else '' # bright red | |
deleted2 = '\033[48;5;88m' if show_color else '' # dark red | |
deleted_both = '\033[48;5;166m' if show_color else '' # orange | |
success = '\033[92m' if show_color else '' # green | |
warning = '\033[93m' if show_color else '' # yellow | |
error = '\033[91m' if show_color else '' # red | |
end = '\033[0m' if show_color else '' | |
# get number of lines in a file, either from a name or a file handle | |
# if given a handle, ensure position is reset to its original value | |
def file_len(f): | |
i = 0 | |
# file | |
try: | |
with open(f, 'r') as fh: | |
for i,l in enumerate(fh): | |
pass | |
# file handle | |
except TypeError: | |
pos = f.tell() | |
f.seek(0) | |
for i,l in enumerate(f): | |
pass | |
f.seek(pos) | |
return i + 1 | |
# get the line numbers of the beginning and end of a conflicted hunk | |
# the file parameter is a path to a file on disk | |
def find_nth_conflicted_hunk(file, n): | |
start = stop = -1 | |
with open(file, 'r') as f: | |
m = 0 | |
for i,l in enumerate(f): | |
if l.startswith(marker_start): | |
m += 1 | |
if (m == n and start == -1): | |
start = i | |
elif start >= 0 and l.startswith(marker_end): | |
stop = i + 1 | |
break | |
if start == -1 or stop == -1: | |
raise IndexError('Hunk %d hunk not found!' % n) | |
return (start, stop) | |
# find nth conflict in file and replace it with given lines | |
# the file parameter is a path to a file on disk | |
# lines is a list of strings | |
def replace_nth_conflicted_hunk_with_lines(file, n, lines): | |
contents = None | |
with open(file, 'r') as f: | |
contents = f.readlines() | |
start, stop = find_nth_conflicted_hunk(file, n) | |
contents[start:stop] = lines | |
with open(file, 'w') as f: | |
f.write(''.join(contents)) | |
# prompt user for input, repeating until one of the allowed responses is given | |
def ask_for_input(msg, allowed=None): | |
if allowed is not None: | |
msg = color.bold + color.info + msg + ' (' + '/'.join(allowed) + ')? ' + color.end | |
while True: | |
r = input(msg).strip().lower() | |
if r in [s.strip().lower() for s in allowed]: | |
break | |
else: | |
msg = color.bold + color.info + msg + color.end | |
r = input(msg) | |
return r | |
# get exit status from a call to os.system | |
# on Windows, os.WEXITSTATUS() doesn't work and os.system() returns the status directly | |
def get_exit_status(exit_code): | |
return exit_code if WINDOWS else os.WEXITSTATUS(exit_code) | |
############# | |
### DIFFS ### | |
############# | |
# print one version of a hunk, highlighting the differences between it and the other version(s) | |
# display is either 'current', 'base' or 'other' | |
# added lines are indicated by a leading '+' and are highlighted in green | |
# deleted lines are indicated by a leading '-' and are highlighted in red | |
# changed lines are indicated by a leading '*' and changes within them are highlighted in either green or red | |
# for current or other version, we just need to highlight the differences between it | |
# and the base version, which can only be changes or additions | |
# for the base version, we want to highlight the differences between it and BOTH | |
# the current and the other version, which can only be changes or deletions | |
# we take the diff with both versions and combine them by keeping all color codes | |
def print_formatted_diff(hunk, display): | |
if display == 'current' or display == 'other': | |
old = hunk[1] | |
new = hunk[0] if display == 'current' else hunk[2] | |
color_added = color.added1 if display == 'current' else color.added2 | |
color_deleted = color.deleted1 if display == 'current' else color.deleted2 | |
clines = format_diff(old, new, 'new', color_added, color_deleted) | |
for ln in clines: | |
print(ln) | |
elif display == 'base': | |
clines1 = format_diff(hunk[1], hunk[0], 'old', color.added1, color.deleted1) | |
clines2 = format_diff(hunk[1], hunk[2], 'old', color.added2, color.deleted2) | |
clines = combine_colors(clines1, clines2, color.deleted1, color.deleted2, color.deleted_both) | |
for ln in clines: | |
print(ln) | |
# add colors and prefixes showing the parts added or deleted between two versions | |
# fold and fnew are file handles for the old and new versions | |
# display must be either 'old' or 'new' | |
# added lines are indicated by a leading '+' and are highlighted using color_added | |
# deleted lines are indicated by a leading '-' and are highlighted using color_deleted | |
# changed lines are indicated by a leading '*' and changes within them are highlighted using color_added and color_deleted | |
def format_diff(fold, fnew, display, color_added, color_deleted): | |
# reset file handles | |
fold.seek(0) | |
fnew.seek(0) | |
# find line numbers of added, deleted and changed lines | |
# for added and deleted groups, get the (zero-indexed) number of the first line and the length of the group | |
# for changed groups, get the (zero-indexed) numbers of the first and one past the last line | |
# ideally we'd use \n as the delimiter, but these break the shell on Windows | |
delimiter = marker_start + "GIT_SUBLINE_MERGE_DELIMITER" + marker_end | |
oldformat = ' --old-group-format="D%de+%dn,%dE+%dN' + delimiter + '" ' | |
newformat = ' --new-group-format="A%de+%dn,%dE+%dN' + delimiter + '" ' | |
changedformat = ' --changed-group-format="C%de+%dl,%dE+%dL' + delimiter + '" ' | |
unchangedformat = ' --unchanged-group-format="" ' | |
cmd = 'diff' + oldformat + newformat + changedformat + unchangedformat + fold.name + ' ' + fnew.name | |
hunks = [h for h in os.popen(cmd).read().split(delimiter) if len(h)] | |
# extract indices from diff output | |
# added and deleted are a list of line numbers | |
# changed are lists of (start, stop) pairs | |
added, deleted, oldchange, newchange = [], [], [], [] | |
for h in hunks: | |
if h[0] == 'A': | |
a = h[1:].split(',')[1] | |
start,length = [int(x) for x in a.split('+')] | |
added += list(range(start, start+length)) | |
elif h[0] == 'D': | |
d = h[1:].split(',')[0] | |
start,length = [int(x) for x in d.split('+')] | |
deleted += list(range(start, start+length)) | |
elif h[0] == 'C': | |
c = h[1:].split(',') | |
start,stop = [int(x) for x in c[0].split('+')] | |
oldchange += [[start, stop]] | |
start,stop = [int(x) for x in c[1].split('+')] | |
newchange += [[start, stop]] | |
# generate a list of lines with prefixes and highlighting | |
lines = [] | |
i, j = 0, 0 | |
old = fold.read().splitlines() | |
new = fnew.read().splitlines() | |
version = old if display == 'old' else new | |
change = oldchange if display == 'old' else newchange | |
while i < len(version): | |
if display == 'new' and i in added: | |
lines.append('+ ' + color_added + version[i] + color.end) | |
elif display == 'old' and i in deleted: | |
lines.append('- ' + color_deleted + version[i] + color.end) | |
elif j < len(change) and i == change[j][0]: | |
oldhunk = old[oldchange[j][0]:oldchange[j][1]] | |
newhunk = new[newchange[j][0]:newchange[j][1]] | |
coloredhunk = format_changed_hunk(oldhunk, newhunk, display, color_added, color_deleted) | |
for ln in coloredhunk: | |
lines.append('* ' + ln) | |
i += change[j][1] - change[j][0] - 1 # 1 will be added later | |
j += 1 | |
else: | |
lines.append(' ' + version[i]) | |
i += 1 | |
return lines | |
# word-level diff with coloring | |
# additions are highlighted using color_added, deletions using color_deleted | |
# h1 and h2 are lists of strings (lines) | |
# display must be either 'old' or 'new' | |
# return value is a list of strings (lines) that matches | |
# either h1 or h2, depending on the value of display | |
def format_changed_hunk(h1, h2, display, color_added, color_deleted): | |
result = '' | |
# split lines at non-word characters | |
# keep track of where the linebreaks are in terms of segment index | |
splA, splB, lcA, lcB = [], [], [0], [0] | |
for ln in h1: | |
segments = re.split('(\W)', ln) | |
splA += segments | |
lcA.append(lcA[-1] + len(segments)) | |
for ln in h2: | |
segments = re.split('(\W)', ln) | |
splB += segments | |
lcB.append(lcB[-1] + len(segments)) | |
lcA.pop(0) | |
lcB.pop(0) | |
# write split lines to temp files | |
tmpA = tempfile.NamedTemporaryFile(mode='w+') | |
tmpA.write('\n'.join(splA)) | |
tmpA.flush() | |
tmpB = tempfile.NamedTemporaryFile(mode='w+') | |
tmpB.write('\n'.join(splB)) | |
tmpB.flush() | |
# use diff to find added and removed section indices | |
old = os.popen('diff --unchanged-line-format="." --old-line-format="-" --new-line-format="" ' + tmpA.name + ' ' + tmpB.name).read() | |
new = os.popen('diff --unchanged-line-format="." --old-line-format="" --new-line-format="+" ' + tmpA.name + ' ' + tmpB.name).read() | |
removed = [] | |
for i,c in enumerate(old): | |
if c == '-': | |
removed.append(i) | |
added = [] | |
for i,c in enumerate(new): | |
if c == '+': | |
added.append(i) | |
if display == 'old': | |
for i,w in enumerate(splA): | |
if i in lcA: result += '\n' | |
if i in removed: result += color_deleted | |
result += w.rstrip('\n') | |
if i in removed: result += color.end | |
elif display == 'new': | |
for i,w in enumerate(splB): | |
if i in lcB: result += '\n' | |
if i in added: result += color_added | |
result += w.rstrip('\n') | |
if i in added: result += color.end | |
tmpA.close() | |
tmpB.close() | |
return result.split('\n') | |
# combine highlighting from two versions of the same (base) hunk and print | |
# lines1 and lines2 are lists of strings (lines) and must be identical | |
# except for highlighting with color1 and color2 | |
# regions which are highlighted in both versions will be printed with color_both | |
def combine_colors(lines1, lines2, color1, color2, color_both): | |
# merge the two versions | |
lines = [] | |
assert len(lines1) == len(lines2) | |
for ln1, ln2 in zip(lines1, lines2): | |
i, j = 0, 0 | |
result = '' | |
highlight1, highlight2 = False, False | |
# remove prefixes (we will add them back later) | |
prefixes = [ln1[0], ln2[0]] | |
ln1, ln2 = ln1[2:], ln2[2:] | |
# i iterates through ln1, j iterates through ln2 | |
while i < len(ln1) or j < len(ln2): | |
# add special characters from version 1 | |
if i < len(ln1): | |
if show_color and ln1[i:i+len(color1)] == color1: | |
result += (color.end + color_both) if highlight2 else color1 | |
highlight1 = True | |
i += len(color1) | |
continue | |
elif show_color and ln1[i:i+len(color.end)] == color.end: | |
highlight1 = False | |
result += color.end | |
if highlight2: result += color2 | |
i += len(color.end) | |
continue | |
# add special characters from version 2 | |
if j < len(ln2): | |
if show_color and ln2[j:j+len(color2)] == color2: | |
result += (color.end + color_both) if highlight1 else color2 | |
highlight2 = True | |
j += len(color2) | |
continue | |
elif show_color and ln2[j:j+len(color.end)] == color.end: | |
highlight2 = False | |
result += color.end | |
if highlight1: result += color1 | |
j += len(color.end) | |
continue | |
# all other characters should match | |
assert ln1[i] == ln2[j] | |
result += ln1[i] | |
i += 1 | |
j += 1 | |
# add prefix and print line | |
prefix = '- ' if '-' in prefixes else '* ' if '*' in prefixes else ' ' | |
lines.append(prefix + result) | |
return lines | |
############### | |
### MERGING ### | |
############### | |
def process_hunk(hunk, index, resolved, num_conflicts): | |
first_run = True | |
fixed = abort = False | |
# get number of lines in current, base and other hunks | |
sizes = [file_len(f) for f in hunk] | |
# only process small hunks | |
if all([s <= MAX_HUNK_SIZE for s in sizes]) and max(abs(sizes[0]-sizes[1]), abs(sizes[2]-sizes[1])) <= MAX_HUNK_SIZE_DIFF: | |
# interactive mode | |
if interactive: | |
print(color.highlight + '\nConflicted hunk %d of %d (spans %d/%d/%d lines) in %s...' % (index, num_conflicts, sizes[0], sizes[1], sizes[2], filename) + color.end) | |
# ask user for action to take | |
while True: | |
for f in hunk: f.seek(0) | |
if first_run: | |
print('') | |
action = 'v' | |
first_run = False | |
else: | |
print('') | |
print(color.bold + ' v - view entire hunk' + color.end) | |
print(color.bold + ' x - view hunk in context' + color.end) | |
print(color.bold + ' s - attempt sub-line merge' + color.end) | |
print(color.bold + ' m - resolve manually' + color.end) | |
print(color.bold + ' c - use current version' + color.end) | |
print(color.bold + ' b - use base version' + color.end) | |
print(color.bold + ' o - use other version' + color.end) | |
print(color.bold + ' k - skip this hunk' + color.end) | |
print(color.bold + ' q - skip all hunks in this file' + color.end) | |
action = ask_for_input('Resolve this hunk', ['v','x','s','m','c','b','o','k','q']) | |
print('') | |
# actions | |
if action == 'x': | |
start, stop = find_nth_conflicted_hunk(current, index-resolved) | |
with open(current, 'r') as c: | |
for i,l in enumerate(c): | |
if i in range(max(0, start-10), start): | |
print(l.rstrip('\n')) | |
elif i >= start: | |
break | |
if action in ['v','x']: | |
print(color.info + marker_start + ' Current version' + color.end) | |
print_formatted_diff(hunk, 'current') | |
print(color.info + marker_base + ' Base version' + color.end) | |
print_formatted_diff(hunk, 'base') | |
print(color.info + marker_other + ' Other version' + color.end) | |
print_formatted_diff(hunk, 'other') | |
print(color.info + marker_end + color.end) | |
if action == 'x': | |
length = file_len(current) | |
with open(current, 'r') as c: | |
for i,l in enumerate(c): | |
if i in range(stop, min(stop+10, length)): | |
print(l.rstrip('\n')) | |
elif i >= stop: | |
break | |
elif action == 's': | |
result = subline_merge_hunk(hunk) | |
if result is not None: | |
print(color.info + marker_start + ' Sub-line merge yields:' + color.end) | |
if len(result): | |
print('\n'.join([x.rstrip('\n') for x in result]).rstrip('\n')) | |
print(color.info + marker_end + color.end + '\n') | |
accept = ask_for_input('Accept sub-line merge', ['y','n']) | |
success = True if accept == 'y' else False | |
if (success): | |
replace_nth_conflicted_hunk_with_lines(current, index-resolved, result) | |
fixed = True | |
break | |
else: | |
print(color.bold + color.error + 'Sub-line merge failed, hunk has overlapping changes' + color.end) | |
elif action == 'm': | |
result = manual_merge_hunk(hunk) | |
if result is not None: | |
print(color.info + marker_start + ' Manual resolution is:' + color.end) | |
if len(result): | |
print('\n'.join([x.rstrip('\n') for x in result]).rstrip('\n')) | |
print(color.info + marker_end + color.end + '\n') | |
accept = ask_for_input('Accept manual resolution', ['y','n']) | |
success = True if accept == 'y' else False | |
if (success): | |
replace_nth_conflicted_hunk_with_lines(current, index-resolved, result) | |
fixed = True | |
break | |
else: | |
print(color.bold + color.warning + 'User cancelled manual resolve' + color.end) | |
elif action in ['c','b','o']: | |
idx = ['c','b','o'].index(action) | |
result = hunk[idx].readlines() | |
branchname = ['Current','Base','Other'][idx] | |
print(color.info + marker_start + ' ' + branchname + ' version is:' + color.end) | |
if len(result): | |
print('\n'.join([x.rstrip('\n') for x in result]).rstrip('\n')) | |
print(color.info + marker_end + color.end + '\n') | |
warn = ['the other branch', 'both branches', 'the current branch'][idx] | |
print(color.bold + color.warning + 'Warning: this will discard changes on ' + warn + '!' + color.end + '\n') | |
accept = ask_for_input('Accept', ['y','n']) | |
success = True if accept == 'y' else False | |
if (success): | |
replace_nth_conflicted_hunk_with_lines(current, index-resolved, result) | |
fixed = True | |
break | |
elif action == 'k': | |
break | |
elif action == 'q': | |
abort = True | |
break | |
# non-interactive mode | |
else: | |
for f in hunk: f.seek(0) | |
result = subline_merge_hunk(hunk) | |
if result is not None: | |
replace_nth_conflicted_hunk_with_lines(current, index-resolved, result) | |
print(color.bold + color.success + 'git-subline-merge resolved conflict %d of %d in %s, resulting hunk was:' % (index, num_conflicts, filename) + color.end) | |
print(color.info + marker_start + color.end) | |
if len(result): print('\n'.join([x.rstrip('\n') for x in result]).rstrip('\n')) | |
print(color.info + marker_end + color.end) | |
fixed = True | |
else: | |
print(color.bold + color.error + 'git-subline-merge failed to resolve conflict %d of %d in %s, hunk has overlapping changes' % (index, num_conflicts, filename) + color.end) | |
# hunk too large | |
else: | |
print(color.bold + color.info + '\nSkipping hunk %d of %d (spans %d/%d/%d lines) in %s, too large...' % (index, num_conflicts, sizes[0], sizes[1], sizes[2], filename) + color.end) | |
return fixed, abort | |
# attempt to do sub-line merging of a conflicted hunk | |
def subline_merge_hunk(hunk): | |
result = None | |
fs = [None, None, None] | |
# separate each character in a string by a newline character | |
# on Windows the temp file can't be open twice, so we have to close it before calling | |
# git-merge-file and delete it manually later | |
for i in range(3): | |
h = hunk[i].read() | |
fs[i] = tempfile.NamedTemporaryFile(mode='w+', delete=False) | |
fs[i].write('\n'.join(h[i:i+1] for i in range(len(h)))) | |
fs[i].close() | |
# attempt merge on separated text | |
status = os.system('git merge-file %s %s %s' % (fs[0].name, fs[1].name, fs[2].name)) | |
num_conflicts = get_exit_status(status) | |
# if successful, take merge result after removing newlines | |
# split into a list of lines | |
if (num_conflicts == 0): | |
with open(fs[0].name, 'r+') as f0: | |
h = f0.read() | |
result = h[0::2].splitlines(True) | |
for f in fs: os.unlink(f.name) | |
return result | |
# open an editor for manual merging of a conflicted hunk | |
def manual_merge_hunk(hunk): | |
# prepare temp file for editing | |
f = tempfile.NamedTemporaryFile(mode='w+', delete=False) | |
f.write(marker_start + ' Current version is:\n') | |
f.write(hunk[0].read()) | |
f.write(marker_base + ' Base version is:\n') | |
f.write(hunk[1].read()) | |
f.write(marker_other + ' Other version is:\n') | |
f.write(hunk[2].read()) | |
f.write(marker_end + '\n') | |
f.close() | |
# imitate the process that Git uses to determine which editor to use | |
editor = os.getenv('GIT_EDITOR') | |
if editor is None: editor = os.popen('git config core.editor').read().rstrip('\n') or None | |
if editor is None: editor = os.getenv('VISUAL') | |
if editor is None: editor = os.getenv('EDITOR') | |
if editor is None: editor = 'vi' | |
# known issue with vim on Mac OS X: can give nonzero exit status even when quit cleanly due to | |
# errors in .vimrc, so check its normal exit code first. | |
success_code = 0 | |
if editor == 'vi': | |
success_code = call(['vi -c "q" %s' % f.name], shell=(not WINDOWS), stdin=sys.stdin) | |
status = call(['%s %s' % (editor, f.name)], shell=(not WINDOWS), stdin=sys.stdin) | |
# if editor returned without an error, return the saved lines | |
result = None | |
if status <= success_code: | |
with open(f.name, 'r') as f: | |
result = f.readlines() | |
os.unlink(f.name) | |
return result | |
############ | |
### MAIN ### | |
############ | |
# invoked by git | |
if len(sys.argv) == 6: | |
# arguments passed in by git: | |
# [1] name of temp file containing base version of file | |
# [2] name of temp file containing current version of file | |
# [3] name of temp file containing other version of file | |
# [4] length of conflict markers (default is 7 for <<<<<<<) | |
# [5] name of conflicted file | |
# the result should be left in the current version (argv[2]) | |
base, current, other, marker_len, filename = sys.argv[1:] | |
marker_len = int(marker_len) | |
marker_start = '<' * marker_len | |
marker_base = '|' * marker_len | |
marker_other = '=' * marker_len | |
marker_end = '>' * marker_len | |
# run git merge on the files using diff3 (result is written to 'current') | |
# the number of conflicts is encoded in the exit status of the git-merge-file command | |
status = os.system('git merge-file --diff3 --marker-size=%d -L "Current version" -L "Base version" -L "Other version" %s %s %s' % (marker_len, current, base, other)) | |
num_conflicts = get_exit_status(status) | |
# invoked manually on a single file | |
elif len(sys.argv) >= 2 and os.path.isfile(sys.argv[1]): | |
# first argument is path to file | |
current = sys.argv[1] | |
filename = current | |
# optional second argument for conflict marker size (default is 7 for <<<<<<<) | |
try: | |
marker_len = int(sys.argv[2]) | |
except ValueError: | |
print('Error: unable to read conflict marker size from argument "%s".' % (sys.argv[2])) | |
sys.exit(1) | |
except IndexError: | |
pass | |
finally: | |
marker_len = 7; | |
# extra arguments are ignored | |
if len(sys.argv) > 3: | |
print('Warning: ignoring extra arguments (%s).' % (', '.join(sys.argv[3:]))) | |
marker_start = '<' * marker_len | |
marker_base = '|' * marker_len | |
marker_other = '=' * marker_len | |
marker_end = '>' * marker_len | |
# use grep to find number of conflicts in the file | |
num_start = int(os.popen('grep -c "^%s" "%s"' % (marker_start, current)).read()) | |
num_base = int(os.popen('grep -c "^%s" "%s"' % (marker_base, current)).read()) | |
num_other = int(os.popen('grep -c "^%s" "%s"' % (marker_other, current)).read()) | |
num_end = int(os.popen('grep -c "^%s" "%s"' % (marker_end, current)).read()) | |
# check that the number of conflict markers match | |
# if they don't, probably the merge wasn't generated using diff3, and we can't resolve conflicts without the base version | |
if num_start == num_base == num_other == num_end: | |
num_conflicts = num_start | |
else: | |
print('Badly formatted conflicts found in file. Possibly you need to change your conflictstyle to diff3?') | |
sys.exit(1) | |
# otherwise show help text | |
else: | |
print("usage: git-subline-merge <file> [conflict-marker-size]\n" | |
" <file>: path to file with conflicts (must be generated using diff3 style, see README)\n" | |
" [conflict-marker-size] (optional): number of characters used in conflict markers (default 7)") | |
sys.exit(1) | |
# sometimes (e.g. during interactive rebase) this script is called even though | |
# there are no conflicts - in that case just exit successfully here | |
if num_conflicts == 0: | |
sys.exit(0) | |
# welcome message | |
if interactive: | |
print(color.welcome + '\ngit-subline-merge v1.0\n' + color.end) | |
# make temporary files | |
# one is a copy of the conflicted 'current', to iterate with | |
# three are used to hold the different versions of each conflicted hunk | |
# open with 'a' so we can append without worrying about newline characters | |
tmp = tempfile.TemporaryFile(mode='w+') | |
with open(current, 'r') as c: tmp.write(c.read()) | |
tmp.seek(0) | |
hunk = [ tempfile.NamedTemporaryFile(mode='a+'), tempfile.NamedTemporaryFile(mode='a+'), tempfile.NamedTemporaryFile(mode='a+') ] | |
# process file line by line | |
resolved = zone = index = 0 | |
for line in tmp: | |
# beginning of conflict zone | |
if line.startswith(marker_start): | |
zone = 1 | |
for f in hunk: | |
f.truncate(0) | |
f.seek(0) | |
# base version of conflict | |
elif line.startswith(marker_base): | |
zone = 2 | |
# other version of conflict | |
elif line.startswith(marker_other): | |
zone = 3 | |
# end of conflict zone | |
elif line.startswith(marker_end): | |
zone = 0 | |
index += 1 | |
fixed, abort = process_hunk(hunk, index, resolved, num_conflicts) | |
if fixed: resolved += 1 | |
elif abort: break | |
# append line to appropriate hunk zone | |
elif zone: | |
hunk[zone-1].write(line) | |
# close all temp files (will be automatically deleted) | |
tmp.close() | |
for h in hunk: h.close() | |
# print resolution | |
newline = '\n' if interactive else '' | |
col = color.success if resolved == num_conflicts else color.warning if resolved > 0 else color.error | |
print(col + color.bold + newline + 'Resolved %d of %d conflicts in %s' % (resolved, num_conflicts, filename) + newline + color.end) | |
# only exit with success if all conflicts were resolved | |
sys.exit(0 if resolved == num_conflicts else 1) | |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env bash | |
script_full_path="$( cd -- "$(dirname "$0")" >/dev/null 2>&1 ; pwd -P )" | |
curl_gitlab() { | |
echo $* >&2 | |
URL_SUFFIX=$1 | |
shift | |
curl \ | |
--silent \ | |
--show-error \ | |
--fail \ | |
--header "PRIVATE-TOKEN: $GITLAB_PAT" \ | |
"https://gitlab.com/api/v4/$URL_SUFFIX" $* | |
} | |
get_project_id() { | |
PROJECTS_FILTER=$1 | |
NAME=$2 | |
curl_gitlab "$PROJECTS_FILTER$NAME" \ | |
| jq -r '.[] | select(.path == "'$NAME'") | "\(.id)"' | |
} | |
merge_mr() { | |
PROJECT_ID=$1 | |
MR_ID=$2 | |
MERGE_WHEN_PIPELINE_SUCCEEDS=${3:-false} | |
curl_gitlab "projects/$PROJECT_ID/merge_requests/$MR_ID/merge?should_remove_source_branch=true&merge_when_pipeline_succeeds=$MERGE_WHEN_PIPELINE_SUCCEEDS" -X PUT | |
ret=$? | |
echo | |
if [ $ret -eq 0 ] ; then | |
echo "MR $MR_ID was set to merge when pipeline succeeds" >&2 | |
else | |
echo "MR $MR_ID was not merged" >&2 | |
fi | |
return $ret | |
} | |
# clones the git repository locally and uses a subline merge algorithm to rebase and force push the branch | |
rebase_local() { | |
local PROJECT_ID=$1 | |
local MR_ID=$2 | |
read -r SOURCE_BRANCH TARGET_BRANCH << EOF | |
$(curl_gitlab "projects/$PROJECT_ID/merge_requests/$MR_ID" | | |
jq -r '[.source_branch, .target_branch] | @tsv') | |
EOF | |
echo "Try a local rebase for MR $MR_ID from $SOURCE_BRANCH to $TARGET_BRANCH" | |
read -r CI_PROJECT_PATH PROJECT_NAME << EOF | |
$(curl_gitlab "projects/$PROJECT_ID" | | |
jq -r '[.path_with_namespace, .path] | @tsv') | |
EOF | |
local GITLAB_USER_NAME="gitlab.ci1" | |
echo "Checkout https://${GITLAB_USER_NAME}@gitlab.com/${CI_PROJECT_PATH}.git in /tmp/$PROJECT_NAME" | |
cd /tmp | |
rm -rf $PROJECT_NAME | |
git clone "https://${GITLAB_USER_NAME}:${GITLAB_PAT}@gitlab.com/${CI_PROJECT_PATH}.git" | |
cd $PROJECT_NAME | |
git checkout $SOURCE_BRANCH | |
git config advice.detachedHead false | |
git config user.email $GITLAB_USER_EMAIL | |
git config user.name "$GITLAB_USER_NAME" | |
# setup subline merge https://github.com/paulaltin/git-subline-merge | |
git config merge.conflictStyle diff3 | |
git config merge.subline.driver "$script_full_path/git-subline-merge %O %A %B %L %P" | |
git config merge.subline.recursive binary | |
echo "* merge=subline" >> .git/info/attributes | |
GIT_SUBLINE_MERGE_NON_INTERACTIVE_MODE=TRUE git rebase --verbose origin/$TARGET_BRANCH | |
pushed=$(git push -f 2>&1 ) | |
if [ "$pushed" = "Everything up-to-date" ] ; then | |
echo "No changes pushed to MR" | |
return 1 | |
else | |
sleep 10 | |
return 0 | |
fi | |
} | |
get_pipeline_status() { | |
PROJECT_ID=$1 | |
MR_IID=$2 | |
curl_gitlab "projects/$PROJECT_ID/merge_requests/$MR_IID" \ | |
| jq -r ' "\(.pipeline.status)"' | |
} | |
get_merge_status() { | |
PROJECT_ID=$1 | |
MR_IID=$2 | |
curl_gitlab "projects/$PROJECT_ID/merge_requests/$MR_IID" \ | |
| jq -r ' "\(.merge_status)"' | |
} | |
all_open_mrs() { | |
AUTHOR_ID=$1 | |
PROJECT_ID=$2 | |
curl_gitlab \ | |
"projects/$PROJECT_ID/merge_requests?state=opened&scope=all&author_id=$AUTHOR_ID&sort=asc" \ | |
| jq -r '.[] | "\(.iid)"' \ | |
| tr '\r\n' ' ' | |
} | |
nurture_mr() { | |
local project_id=$1 | |
local mr_id=$2 | |
local project_name=$3 | |
echo "nurturing MR $mr_id" >&2 | |
pipeline_status=$(get_pipeline_status $project_id $mr_id) | |
echo "pipeline status is $pipeline_status" >&2 | |
merge_status=$(get_merge_status $project_id $mr_id) | |
echo "merge status is $merge_status" >&2 | |
if [ "$pipeline_status" = "success" ] && [ "$merge_status" = "can_be_merged" ] ; then | |
if ! merge_mr $project_id $mr_id ; then | |
if rebase_local $project_id $mr_id ; then | |
merge_mr $project_id $mr_id true | |
fi | |
fi | |
else | |
if rebase_local $project_id $mr_id ; then | |
merge_mr $project_id $mr_id true | |
fi | |
fi | |
} | |
nurture_project() { | |
project_name=$1 | |
project_id=$(get_project_id $PROJECTS_FILTER $project_name) | |
if [ "$project_id" = "" ] ; then | |
echo "no id found for project $project_name" >&2 | |
exit 1 | |
fi | |
echo "" >&2 | |
echo "nurturing project $project_name (id $project_id)" >&2 | |
echo "==========================================" >&2 | |
local nurtured_count=0 | |
local mrs=$(all_open_mrs $AUTHOR_ID $project_id) | |
echo "MRs found : [$mrs]" >&2 | |
for mr_id in $mrs ; do | |
((nurtured_count < MAX_MRS)) || return 0 | |
if nurture_mr $project_id $mr_id $project_name; then | |
((nurtured_count++)) | |
fi | |
done | |
# finish with a return code 0 even if some MRs could not be merged / rebased | |
echo "finished with project $project_name (id $project_id)" >&2 | |
echo "" >&2 | |
} | |
usage() { | |
echo "Usage:" | |
echo "$0 GITLAB_ACCESS_TOKEN AUTHOR_ID FILTER 'name1 name2 name3' MAX_MRS" | |
echo | |
echo "examples:" | |
echo "$0 df12fs1sdd 12345 'groups/789/search?scope=projects&search=' 'a b c' 3" | |
echo "$0 df12fs1sdd 12345 'users/123/projects?search=' 'a b c'" | |
exit 1 | |
} | |
[ $# -eq 5 ] || usage | |
GITLAB_PAT=$1 | |
AUTHOR_ID=$2 | |
PROJECTS_FILTER=$3 | |
PROJECT_NAMES="$4" | |
MAX_MRS=$5 | |
for project_name in $PROJECT_NAMES ; do | |
nurture_project $project_name | |
done |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment