Skip to content

Instantly share code, notes, and snippets.

@jacobabrahamb4
Last active March 18, 2024 12:05
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 3 You must be signed in to fork a gist
  • Save jacobabrahamb4/a60624d6274ece7a0bd2d141b53407bc to your computer and use it in GitHub Desktop.
Save jacobabrahamb4/a60624d6274ece7a0bd2d141b53407bc to your computer and use it in GitHub Desktop.
Display diff between two commits. Tested working on ubuntu and Windows. Install beyond compare or meld before use. Works well with small-medium projects.
#!/usr/bin/env python3
"""
How to use this script?
-----------------------
Prerequisites:
1. python3, git and Beyond Compare/meld should be installed.
LINUX:
1. Create a directory ~/bin in your home directory.
2. Copy the script 'bdiff.py' to the ~/bin directory
3. Open the shell go to the directory ~/bin and do 'chmod +x bdiff.py'
4. Open the ~/.bashrc file and add the ~/bin directory to the PATH variable,
add the following line at the end of the file and save the ~/.bashrc file
export PATH=$PATH:~/bin
5. Execute the following command to source the ~.bashrc file.
source ~/.bashrc
6. Go to the project directory in shell.
Examples:
Usage: bdiff.py <project_folder> <commit_id_one> <commit_id_two>
Example: bdiff.py . 0 1
Example: bdiff.py . fhejk7fe d78ewg9we
Example: bdiff.py . 0 d78ewg9we
Example: bdiff.py . HEAD d78ewg9we
Example: bdiff.py . HEAD-4 87
WINDOWS:
1. Create a directory C:\bin\
2. Copy the script bdiff.py to the C:\bin\ directory.
3. Add the paths C:\Program Files\Beyond Compare\ and C:\Program Files\Meld\ to the PATH environment variable
4. Open the project directory in windows cmd.
Examples:
Usage: py.exe C:\bin\bdiff.py <project_folder> <commit_id_one> <commit_id_two>
Example: py.exe C:\bin\bdiff.py . 0 1
Example: py.exe C:\bin\bdiff.py . fhejk7fe d78ewg9we
Example: py.exe C:\bin\bdiff.py . 0 d78ewg9we
Example: py.exe C:\bin\bdiff.py . HEAD d78ewg9we
Example: py.exe C:\bin\bdiff.py . HEAD-4 87
"""
import sys, subprocess, os, shutil
DIFF_TOOLS = {'posix' : ['bcompare', 'meld'], 'nt' : ['BComp.exe', 'Meld.exe']}
WHICH = {'posix' : 'which', 'nt' : 'where'}
DU = {'posix' : ['du', '-sh'], 'nt' : None}
RM = {'posix' : ['rm', '-rf'], 'nt' : ['rmdir']}
SLASH = {'posix' : '/', 'nt' : '\\'}
CMD_TOOLS = []
HEAD = 'HEAD'
TMP = {'posix': '/tmp/', 'nt' : 'C:\Temp\\'}
COMMIT_ID_LENGTH = 7
PACKAGES = ['shutil']
class OS(object):
_instance = None
def __init__(self):
raise RuntimeError('Call instance() instead!')
@classmethod
def instance(cls):
if cls._instance is None:
print('Creating new instance')
cls._instance = cls.__new__(cls)
return cls._instance
def get_diff_tools(self):
return DIFF_TOOLS[os.name]
def which(self):
return WHICH[os.name]
def tmp(self):
return TMP[os.name]
def du(self):
return DU[os.name]
def rm(self):
return RM[os.name]
def slash(self):
return SLASH[os.name]
def isWindows(self):
return os.name == 'nt'
def isLinux(self):
return os.name == 'posix'
class Shell(object):
_instance = None
def __init__(self):
raise RuntimeError('Call instance() instead!')
@classmethod
def instance(cls):
if cls._instance is None:
print('Creating new instance')
cls._instance = cls.__new__(cls)
return cls._instance
def execute_command(self, command):
print("Executing command: " + str(command))
if command: return subprocess.check_output(command).decode()
return None
def which(self, tool):
return self.execute_command([OS.instance().which(), tool]).strip()
def dir_name(self, commit):
return commit if commit else None
def tmp(self, name):
return OS.instance().tmp() + self.dir_name(name) + OS.instance().slash()
def get_dir_size(self, name):
du = OS.instance().du()
if du and isinstance(du, list): du.extend([name])
else:
return ''
return self.execute_command(du).strip()
def cleanup(self, commit):
dir = self.tmp(commit)
if not os.path.exists(dir): return
if OS.instance().isWindows():
shutil.rmtree(dir)
else:
rm = OS.instance().rm()
if rm and isinstance(rm, list):
rm.extend([self.tmp(commit)])
try:
self.execute_command(rm)
except FileNotFoundError:
pass
def print_usage():
if OS.instance().isLinux():
print('Usage: bdiff.py <project_folder> <commit_id_one> <commit_id_two>\n'
'Example: bdiff.py . 0 1\n'
'Example: bdiff.py . fhejk7fe d78ewg9we\n'
'Example: bdiff.py . 0 d78ewg9we\n'
'Example: bdiff.py . HEAD d78ewg9we\n'
'Example: bdiff.py . HEAD-4 87\n')
else:
print('Usage: py.exe C:\bin\bdiff.py <project_folder> <commit_id_one> <commit_id_two>\n'
'Example: py.exe C:\bin\bdiff.py . 0 1\n'
'Example: py.exe C:\bin\bdiff.py . fhejk7fe d78ewg9we\n'
'Example: py.exe C:\bin\bdiff.py . 0 d78ewg9we\n'
'Example: py.exe C:\bin\bdiff.py . HEAD d78ewg9we\n'
'Example: py.exe C:\bin\bdiff.py . HEAD-4 87\n')
class Env(object):
def __init__(self):
self.__diff_tool = None
self.shell = Shell.instance()
self.os = OS.instance()
def check_dependencies(self):
status = True
found = False
for tool in self.os.get_diff_tools():
if self.__check_tool(tool):
print('Diff tool found: ' + tool)
found = True
self.__diff_tool = tool
break
if not found:
self.__print_install('commandline tool ' + self.os.get_diff_tools()[0] + ' or ' + self.os.get_diff_tools()[1])
status = False
for tool in CMD_TOOLS:
if self.__check_tool(tool):
print('Commandline tool already installed: ' + tool)
else:
self.__print_install('commandline tool ' + tool)
status = False
for package in PACKAGES:
if self.__check_package(package):
print('Python package already installed: ' + package)
else:
self.__print_install('python package ' + package)
status = False
return status
def get_diff_tool(self):
return self.__diff_tool
def __check_tool(self, tool):
try:
output = self.shell.which(tool)
if tool in output:
print("Already installed: " + tool)
except subprocess.CalledProcessError:
return False
return True
def __print_install(self, str):
print('Please install ' + str + ' before use!')
def __check_package(self, package):
try:
__import__(package)
except ImportError as e:
print("Unable to check package: " + package)
return False
return True
def parse_opt(self, args):
args_length = len(args)
cwd = os.getcwd()
if args_length == 2:
return cwd, args[1] + '-1', args[1]
elif args_length == 3:
return cwd, args[1], args[2]
elif args_length == 4:
return args[1], args[2], args[3]
else:
return None
class Executor(object):
def __init__(self, name, first, second):
self.shell = Shell.instance()
self.name = name
self.first = first
self.second = second
self.commit1 = None
self.commit2 = None
def __get_commit_id(self, position):
print('Get commit id: ' + position)
index = -1
command = ['git', '-C', self.name, 'log', '--pretty=format:"%h"', '--abbrev=' + str(COMMIT_ID_LENGTH)]
#reverse = ['--reverse']
if position == HEAD:
return self.__rev_parse(position)
elif HEAD in position and ('-' in position or '~' in position):
try:
return self.__rev_parse(position)
except ValueError:
print("Error parsing commit ids!")
return None
elif HEAD in position:
print("Error parsing commit ids. Wrong commandline arguments!")
return None
elif '-' in position:
commitid = position.split()[0][:COMMIT_ID_LENGTH]
index = self.shell.execute_command(command).splitlines().index(commitid)
try:
index -= int(position.split()[1])
except ValueError:
print("Unable to parse the input!")
return None
elif position.isdigit():
#command.extend(reverse)
index = int(position)
else:
return position[:COMMIT_ID_LENGTH]
if index >= 0:
logs = self.shell.execute_command(command).splitlines()
commitid = logs[index].strip().replace('"', '')
print('Commit id: ----------->' + commitid)
return commitid
else:
return None
def validate(self):
self.commit1, self.commit2 = self.__get_commit_id(first), self.__get_commit_id(second)
if not self.commit1 and self.commit2:
print("Unable to get the commit ids!")
return False
if self.__validate(self.commit1) and self.__validate(self.commit2) is False:
return False
return True
def execute(self, tool):
if not self.__checkout():
print('Unable to checkout the project. May be you are running out of free space!')
return False
if not self.__compare(tool):
return False
return True
def __checkout(self):
return self.__checkout_commit(self.commit1) and self.__checkout_commit(self.commit2)
def __checkout_commit(self, commit):
if commit:
print('Cloning the project to temp directory to compare. Project size: ' + self.shell.get_dir_size(self.name) +
' Please wait. This may take time!')
self.shell.execute_command(['git', 'clone', self.name, self.shell.tmp(commit)])
self.shell.execute_command(['git', '-C', self.shell.tmp(commit), 'checkout', commit])
else:
#if not self.shell.execute_command(['mkdir', self.shell.tmp('0')]): return False
return False
return True
def __validate(self, commit):
if not commit:
print("Invalid commit id!")
return False
try:
self.shell.execute_command(['git', '-C', self.name, 'cat-file', '-t', commit])
except subprocess.CalledProcessError:
return False
return True
def __rev_parse(self, position):
if '-' in position:
position.replace('-', '~')
command = ['git', '-C', self.name, 'rev-parse', position]
return self.shell.execute_command(command).strip()
def cleanup(self):
if not self.__cleanup(self.commit1) and self.__cleanup(self.commit2):
print('Unable to remove temporary files!')
def __cleanup(self, commit):
return self.shell.cleanup(commit)
def __compare(self, tool):
return self.shell.execute_command([tool, self.shell.tmp(self.commit1), self.shell.tmp(self.commit2)])
if __name__ == '__main__':
env = Env()
if not env.check_dependencies():
Shell.instance().print_usage()
sys.exit(1)
name, first, second = env.parse_opt(sys.argv)
if not name or not first or not second:
print('Unable to parse the commandline!')
Shell.instance().print_usage()
sys.exit(1)
executor = Executor(name, first, second)
if not executor.validate():
print('Validation failed. Exiting!')
sys.exit(1)
executor.cleanup()
try:
executor.execute(env.get_diff_tool())
except KeyboardInterrupt:
pass
finally:
executor.cleanup()
sys.exit(0)
@saiftheboss7
Copy link

How to use it??

@Somayeh12
Copy link

Somayeh12 commented Jul 17, 2019

These are two commits for gson project:
'0eab91a57fee3c86b5f7b904bb8211ecf843b450'
'9e9ebf095e2b9d4ba4d3955f4fa59128bb2420c0'
Considering the project is cloned in 'D:\projects\gson'
How do you use this code to find the files that have been modified between these tow commits?
Can you please provide an example?

@rcpao-enmotus
Copy link

rcpao-enmotus commented Oct 9, 2019

It is assumed 'meld' is installed:
local_git_dir$ bdiff.py . 15d3c2ec3d007a5f0a6387a4998dc6fbcd9d93f5 c182600f5ff2491964af59c32933efa73575949d
or
local_git_dir$ bdiff.py . HEAD c182600f5ff2491964af59c32933efa73575949d
or
$ bdiff.py local_git_dir HEAD c182600f5ff2491964af59c32933efa73575949d

The following will all error out (even after replacing "catch" with "except"):
$ bdiff.py local_git_dir 0 1
$ bdiff.py local_git_dir HEAD HEAD-1

@jacobabrahamb4
Copy link
Author

Python3 update

#!/usr/bin/env python3
import sys, subprocess, os

TOOLS = ['bcompare', 'meld']
HEAD = 'HEAD'
TMP = '/tmp/'


def execute (command):
	return subprocess.check_output(command).decode()


def which(tool):
	return execute(['which', tool]).strip()


def getTool():
	for tool in TOOLS:
		try:
			out = which(tool)
			if tool in out:
				return tool
		except subprocess.CalledProcessError:
			pass
	return None


def printUsageAndExit():
	print('Usage: python bdiff.py <project> <commit_one> <commit_two>\n'
		  'Example: python bdiff.py <project> 0 1\n'
		  'Example: python bdiff.py <project> fhejk7fe d78ewg9we\n'
		  'Example: python bdiff.py <project> 0 d78ewg9we\n'
		  'Example: python bdiff.py <project> HEAD d78ewg9we\n'
		  'Example: python bdiff.py <project> HEAD-4 87\n')
	sys.exit(0)


def revParse(name, position):
	if '-' in position:
		position.replace('-', '~')
	command = ['git', '-C', name, 'rev-parse', position]
	return execute(command).strip()


def getCommitId(name, position):
	index = -1;
	command = ['git', '-C', name, 'log', '--pretty=format:"%h"', '--abbrev=7']
	reverse = ['--reverse']
	if position == HEAD:
		return revParse(name, position)
	elif HEAD in position and ('-' in position or '~' in position):
		try:
			return revParse(name, position)
		except ValueError:
			print("Error in parsing commit ids!")
			sys.exit(0)
	elif HEAD in position:
		print("Error in parsing commit ids!")
		sys.exit(-1)
	elif '-' in position:
		commitid = position.split()[0][:7]
		index = execute(command).splitlines().index(commitid)
		try:
			index -= int(position.split()[1])
		except ValueError:
			print("Unable to paser the input!")
			sys.exit(-1)
	elif position.isdigit():
		command.extend(reverse)
		index = int(position)
	else:
		return position
	if index >= 0:
		logs = execute(command).splitlines()
		return logs[index].strip()
	else:
		return None


def dir_name(commit):
	return commit if commit else '0'


def tmp(name):
	return TMP + dir_name(name)


def validate(name, commit):
	if not commit:
		print("Nothing to do, exit!")
		return False
	try:
		if commit:
			execute(['git', '-C', name, 'cat-file', '-t', commit])
	except subprocess.CalledProcessError:
		return False
	return True


cleanup = lambda commit: execute(['rm', '-rf', tmp(commit)])


def checkoutCommit(name, commit):
	if commit:
		execute(['git', 'clone', name, tmp(commit)])
		execute(['git', '-C', tmp(commit), 'checkout', commit])
	else:
		execute(['mkdir', tmp('0')])


def compare(tool, commit1, commit2):
		execute([tool, tmp(commit1), tmp(commit2)])


def parseOpt():
	if len(sys.argv) == 2:
		return os.getcwd(), sys.argv[1] + '-1', sys.argv[1]
	elif len(sys.argv) == 3:
		return os.getcwd(), sys.argv[1], sys.argv[2]
	elif len(sys.argv) == 4:
		return sys.argv[1], sys.argv[2], sys.argv[3]
	else:
		printUsageAndExit()


if __name__ == '__main__':
	tool = getTool()
	if not tool:
		print("No GUI diff tools, install bcompare or meld")
		sys.exit(0)

	name, first, second = parseOpt()

	commit1, commit2 = getCommitId(name, first), getCommitId(name, second)

	if validate(name, commit1) and validate(name, commit2) is False:
		sys.exit(0)

	cleanup(commit1), cleanup(commit2)

	try:
		checkoutCommit(name, commit1), checkoutCommit(name, commit2)
		compare(tool, commit1, commit2)
	except KeyboardInterrupt:
		pass
	finally:
		cleanup(commit1), cleanup(commit2)
	sys.exit(0)

(github won't let me just attach file, that is why I am adding this as comment)

Please fork and make changes.

@jacobabrahamb4
Copy link
Author

How to use it??

Please refer the printUsageAndExit function.

@jacobabrahamb4
Copy link
Author

Updated the script to support python 3. Works on ubuntu. Not built for Windows.

@jacobabrahamb4
Copy link
Author

Updated the script with some minor modifications. Tested working on Ubuntu 20.04.6 LTS.

@jacobabrahamb4
Copy link
Author

Tested working on Ubuntu 20.04.6 LTS and Windows 11.

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