tools tools tools!
This commit is contained in:
@@ -5,7 +5,7 @@ Use of these hooks and drivers is optional and they must be installed
|
||||
explicitly before they take effect.
|
||||
|
||||
To install the current set of hooks, or update if new hooks are added, run
|
||||
`install.bat` (Windows) or `install.sh` (Unix-like) as appropriate.
|
||||
`Install.bat` (Windows) or `tools/hooks/install` (Unix-like) as appropriate.
|
||||
|
||||
Hooks expect a Unix-like environment on the backend. Usually this is handled
|
||||
automatically by GUI tools like TortoiseGit and GitHub for Windows, but
|
||||
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
@call "%~dp0\..\bootstrap\python" -m hooks.install --uninstall %*
|
||||
@pause
|
||||
Regular → Executable
+1
-1
@@ -1,2 +1,2 @@
|
||||
#!/bin/sh
|
||||
exec tools/hooks/python.sh -m merge_driver_dmi "$@"
|
||||
exec tools/bootstrap/python -m dmi.merge_driver "$@"
|
||||
|
||||
+2
-16
@@ -1,16 +1,2 @@
|
||||
@echo off
|
||||
cd %~dp0
|
||||
for %%f in (*.hook) do (
|
||||
echo Installing hook: %%~nf
|
||||
copy %%f ..\..\.git\hooks\%%~nf >nul
|
||||
)
|
||||
for %%f in (*.merge) do (
|
||||
echo Installing merge driver: %%~nf
|
||||
echo [merge "%%~nf"]^
|
||||
|
||||
driver = tools/hooks/%%f %%P %%O %%A %%B %%L >> ..\..\.git\config
|
||||
)
|
||||
echo Installing Python dependencies
|
||||
python -m pip install -r ..\mapmerge2\requirements.txt
|
||||
echo Done
|
||||
pause
|
||||
@call "%~dp0\..\bootstrap\python" -m hooks.install %*
|
||||
@pause
|
||||
|
||||
Executable
+101
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env python3
|
||||
# hooks/install.py
|
||||
#
|
||||
# This script is configured by adding `*.hook` and `*.merge` files in the same
|
||||
# directory. Such files should be `#!/bin/sh` scripts, usually invoking Python.
|
||||
# This installer will have to be re-run any time a hook or merge file is added
|
||||
# or removed, but not when they are changed.
|
||||
#
|
||||
# Merge drivers will also need a corresponding entry in the `.gitattributes`
|
||||
# file.
|
||||
|
||||
import os
|
||||
import stat
|
||||
import glob
|
||||
import re
|
||||
import pygit2
|
||||
import shlex
|
||||
|
||||
|
||||
def write_hook(fname, command):
|
||||
with open(fname, 'w', encoding='utf-8', newline='\n') as f:
|
||||
print("#!/bin/sh", file=f)
|
||||
print("exec", command, file=f)
|
||||
|
||||
# chmod +x
|
||||
st = os.stat(fname)
|
||||
if not hasattr(st, 'st_file_attributes'):
|
||||
os.chmod(fname, st.st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
|
||||
|
||||
def _find_stuff(target=None):
|
||||
repo_dir = pygit2.discover_repository(target or os.getcwd())
|
||||
repo = pygit2.Repository(repo_dir)
|
||||
# Strips any active worktree to find the hooks directory.
|
||||
root_repo_dir = re.sub(r'/.git/worktrees/[^/]+/', '/.git/', repo_dir)
|
||||
hooks_dir = os.path.join(root_repo_dir, 'hooks')
|
||||
return repo, hooks_dir
|
||||
|
||||
|
||||
def uninstall(target=None, keep=()):
|
||||
repo, hooks_dir = _find_stuff(target)
|
||||
|
||||
# Remove hooks
|
||||
for fname in glob.glob(os.path.join(hooks_dir, '*')):
|
||||
_, shortname = os.path.split(fname)
|
||||
if not fname.endswith('.sample') and f"{shortname}.hook" not in keep:
|
||||
print('Removing hook:', shortname)
|
||||
os.unlink(fname)
|
||||
|
||||
# Remove merge driver configuration
|
||||
for entry in repo.config:
|
||||
match = re.match(r'^merge\.([^.]+)\.driver$', entry.name)
|
||||
if match and f"{match.group(1)}.merge" not in keep:
|
||||
print('Removing merge driver:', match.group(1))
|
||||
del repo.config[entry.name]
|
||||
|
||||
|
||||
def install(target=None):
|
||||
repo, hooks_dir = _find_stuff(target)
|
||||
tools_hooks = os.path.split(__file__)[0]
|
||||
|
||||
keep = set()
|
||||
for full_path in glob.glob(os.path.join(tools_hooks, '*.hook')):
|
||||
_, fname = os.path.split(full_path)
|
||||
name, _ = os.path.splitext(fname)
|
||||
print('Installing hook:', name)
|
||||
keep.add(fname)
|
||||
relative_path = shlex.quote(os.path.relpath(full_path, repo.workdir).replace('\\', '/'))
|
||||
write_hook(os.path.join(hooks_dir, name), f'{relative_path} "$@"')
|
||||
|
||||
# Use libgit2 config manipulation to set the merge driver config.
|
||||
for full_path in glob.glob(os.path.join(tools_hooks, '*.merge')):
|
||||
# Merge drivers are documented here: https://git-scm.com/docs/gitattributes
|
||||
_, fname = os.path.split(full_path)
|
||||
name, _ = os.path.splitext(fname)
|
||||
print('Installing merge driver:', name)
|
||||
keep.add(fname)
|
||||
# %P: "real" path of the file, should not usually be read or modified
|
||||
# %O: ancestor's version
|
||||
# %A: current version, and also the output path
|
||||
# %B: other branches' version
|
||||
# %L: conflict marker size
|
||||
relative_path = shlex.quote(os.path.relpath(full_path, repo.workdir).replace('\\', '/'))
|
||||
repo.config[f"merge.{name}.driver"] = f'{relative_path} %P %O %A %B %L'
|
||||
|
||||
uninstall(target, keep=keep)
|
||||
|
||||
|
||||
def main(argv):
|
||||
if len(argv) <= 1:
|
||||
return install()
|
||||
elif argv[1] == '--uninstall':
|
||||
return uninstall()
|
||||
else:
|
||||
print("Usage: python -m hooks.install [--uninstall]")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import sys
|
||||
exit(main(sys.argv))
|
||||
Regular → Executable
+2
-20
@@ -1,20 +1,2 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
shopt -s nullglob
|
||||
cd "$(dirname "$0")"
|
||||
for f in *.hook; do
|
||||
echo Installing hook: ${f%.hook}
|
||||
cp $f ../../.git/hooks/${f%.hook}
|
||||
done
|
||||
for f in *.merge; do
|
||||
echo Installing merge driver: ${f%.merge}
|
||||
git config --replace-all merge.${f%.merge}.driver "tools/hooks/$f %P %O %A %B %L"
|
||||
done
|
||||
|
||||
echo "Installing tgui hooks"
|
||||
../../tgui/bin/tgui --install-git-hooks
|
||||
|
||||
echo "Installing Python dependencies"
|
||||
./python.sh -m pip install -r ../mapmerge2/requirements.txt
|
||||
|
||||
echo "Done"
|
||||
#!/bin/sh
|
||||
exec "$(dirname "$0")/../bootstrap/python" -m hooks.install "$@"
|
||||
|
||||
Executable
+169
@@ -0,0 +1,169 @@
|
||||
# merge_frontend.py
|
||||
import sys
|
||||
import io
|
||||
import os
|
||||
import pygit2
|
||||
import collections
|
||||
import typing
|
||||
|
||||
|
||||
ENCODING = 'utf-8'
|
||||
|
||||
|
||||
class MergeReturn(typing.NamedTuple):
|
||||
success: bool
|
||||
merge_result: typing.Optional[object]
|
||||
|
||||
|
||||
class MergeDriver:
|
||||
driver_id: typing.Optional[str] = None
|
||||
|
||||
def pre_announce(self, path: str):
|
||||
"""
|
||||
Called before merge() is called, with a human-friendly path for output.
|
||||
"""
|
||||
print(f"Merging {self.driver_id}: {path}")
|
||||
|
||||
def merge(self, base: typing.BinaryIO, left: typing.BinaryIO, right: typing.BinaryIO) -> MergeReturn:
|
||||
"""
|
||||
Read from three BinaryIOs: base (common ancestor), left (ours), and
|
||||
right (theirs). Perform the actual three-way merge operation. Leave
|
||||
conflict markers if necessary.
|
||||
|
||||
Return (False, None) to indicate the merge driver totally failed.
|
||||
Return (False, merge_result) if the result contains conflict markers.
|
||||
Return (True, merge_result) if everything went smoothly.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def to_file(self, output: typing.BinaryIO, merge_result: object):
|
||||
"""
|
||||
Save the merge() result to the given output stream.
|
||||
Override this if the merge() result is not bytes or str.
|
||||
"""
|
||||
if isinstance(merge_result, bytes):
|
||||
output.write(merge_result)
|
||||
elif isinstance(merge_result, str):
|
||||
with io.TextIOWrapper(output, ENCODING) as f:
|
||||
f.write(merge_result)
|
||||
else:
|
||||
raise NotImplementedError
|
||||
|
||||
def post_announce(self, success: bool, merge_result: object):
|
||||
"""
|
||||
Called after merge() is called, to warn the user if action is needed.
|
||||
"""
|
||||
if not success:
|
||||
print("!!! Manual merge required")
|
||||
if merge_result:
|
||||
print(" A best-effort merge was performed. You must finish the job yourself.")
|
||||
else:
|
||||
print(" No merge was possible. You must resolve the conflict yourself.")
|
||||
|
||||
def main(self, args: typing.List[str] = None):
|
||||
return _main(self, args or sys.argv[1:])
|
||||
|
||||
|
||||
def _main(driver: MergeDriver, args: typing.List[str]):
|
||||
if len(args) > 0 and args[0] == '--posthoc':
|
||||
return _posthoc_main(driver, args[1:])
|
||||
else:
|
||||
return _driver_main(driver, args)
|
||||
|
||||
|
||||
def _driver_main(driver: MergeDriver, args: typing.List[str]):
|
||||
"""
|
||||
Act like a normal Git merge driver, called by Git during a merge.
|
||||
"""
|
||||
if len(args) != 5:
|
||||
print("merge driver called with wrong number of arguments")
|
||||
print(" usage: %P %O %A %B %L")
|
||||
return 1
|
||||
|
||||
path, path_base, path_left, path_right, _ = args
|
||||
driver.pre_announce(path)
|
||||
|
||||
with open(path_base, 'rb') as io_base:
|
||||
with open(path_left, 'rb') as io_left:
|
||||
with open(path_right, 'rb') as io_right:
|
||||
success, merge_result = driver.merge(io_base, io_left, io_right)
|
||||
|
||||
if merge_result:
|
||||
# If we got anything, write it to the working directory.
|
||||
with open(path_left, 'wb') as io_output:
|
||||
driver.to_file(io_output, merge_result)
|
||||
|
||||
driver.post_announce(success, merge_result)
|
||||
if not success:
|
||||
# If we were not successful, do not mark the conflict as resolved.
|
||||
return 1
|
||||
|
||||
|
||||
def _posthoc_main(driver: MergeDriver, args: typing.List[str]):
|
||||
"""
|
||||
Apply merge driver logic to a repository which is already in a conflicted
|
||||
state, running the driver on any conflicted files.
|
||||
"""
|
||||
repo_dir = pygit2.discover_repository(os.getcwd())
|
||||
repo = pygit2.Repository(repo_dir)
|
||||
conflicts = repo.index.conflicts
|
||||
if not conflicts:
|
||||
print("There are no unresolved conflicts.")
|
||||
return 0
|
||||
|
||||
all_success = True
|
||||
index_changed = False
|
||||
any_attempted = False
|
||||
for base, left, right in list(conflicts):
|
||||
if not base or not left or not right:
|
||||
# (not left) or (not right): deleted in one branch, modified in the other.
|
||||
# (not base): added differently in both branches.
|
||||
# In either case, there's nothing we can do for now.
|
||||
continue
|
||||
|
||||
path = left.path
|
||||
if not _applies_to(repo, driver, path):
|
||||
# Skip the file if it's not the right extension.
|
||||
continue
|
||||
|
||||
any_attempted = True
|
||||
driver.pre_announce(path)
|
||||
io_base = io.BytesIO(repo[base.id].data)
|
||||
io_left = io.BytesIO(repo[left.id].data)
|
||||
io_right = io.BytesIO(repo[right.id].data)
|
||||
success, merge_result = driver.merge(io_base, io_left, io_right)
|
||||
if merge_result:
|
||||
# If we got anything, write it to the working directory.
|
||||
with open(os.path.join(repo.workdir, path), 'wb') as io_output:
|
||||
driver.to_file(io_output, merge_result)
|
||||
|
||||
if success:
|
||||
# If we were successful, mark the conflict as resolved.
|
||||
with open(os.path.join(repo.workdir, path), 'rb') as io_readback:
|
||||
contents = io_readback.read()
|
||||
merged_id = repo.create_blob(contents)
|
||||
repo.index.add(pygit2.IndexEntry(path, merged_id, left.mode))
|
||||
del conflicts[path]
|
||||
index_changed = True
|
||||
if not success:
|
||||
all_success = False
|
||||
driver.post_announce(success, merge_result)
|
||||
|
||||
if index_changed:
|
||||
repo.index.write()
|
||||
|
||||
if not any_attempted:
|
||||
print("There are no unresolved", driver.driver_id, "conflicts.")
|
||||
|
||||
if not all_success:
|
||||
# Not usually observed, but indicate the failure just in case.
|
||||
return 1
|
||||
|
||||
|
||||
def _applies_to(repo: pygit2.Repository, driver: MergeDriver, path: str):
|
||||
"""
|
||||
Check if the current merge driver is a candidate to handle a given path.
|
||||
"""
|
||||
if not driver.driver_id:
|
||||
raise ValueError('Driver must have ID to perform post-hoc merge')
|
||||
return repo.get_attr(path, 'merge') == driver.driver_id
|
||||
Regular → Executable
+1
-2
@@ -1,3 +1,2 @@
|
||||
#!/bin/sh
|
||||
# `sh` must be used here instead of `bash` to support GitHub Desktop.
|
||||
exec tools/hooks/python.sh -m precommit
|
||||
exec tools/bootstrap/python -m mapmerge2.precommit
|
||||
|
||||
Regular → Executable
+12
-27
@@ -1,32 +1,17 @@
|
||||
#!/bin/sh
|
||||
# `sh` must be used here instead of `bash` to support GitHub Desktop.
|
||||
set -e
|
||||
|
||||
# Strip the "App Execution Aliases" from $PATH. Even if the user installed
|
||||
# Python using the Windows Store on purpose, these aliases always generate
|
||||
# "Permission denied" errors when sh.exe tries to invoke them.
|
||||
PATH=$(echo "$PATH" | tr ":" "\n" | grep -v "AppData/Local/Microsoft/WindowsApps" | tr "\n" ":")
|
||||
|
||||
# Try to find a Python executable.
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
PY=python3
|
||||
elif command -v python >/dev/null 2>&1; then
|
||||
PY=python
|
||||
elif command -v py >/dev/null 2>&1; then
|
||||
PY="py -3"
|
||||
if [ "$*" = "-m precommit" ]; then
|
||||
echo "Hooks are being updated..."
|
||||
echo "Details: https://github.com/tgstation/tgstation/pull/55658"
|
||||
if [ "$(uname -o)" = "Msys" ]; then
|
||||
tools/hooks/Install.bat
|
||||
else
|
||||
tools/hooks/install.sh
|
||||
fi
|
||||
echo "---------------"
|
||||
exec tools/hooks/pre-commit.hook
|
||||
else
|
||||
echo "Please install Python from https://www.python.org/downloads/"
|
||||
echo "tools/hooks/python.sh is replaced by tools/bootstrap/python"
|
||||
echo "Details: https://github.com/tgstation/tgstation/pull/55658"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Deduce the path separator and add the mapmerge package to the search path.
|
||||
PATHSEP=$($PY - <<'EOF'
|
||||
import sys, os
|
||||
if sys.version_info.major != 3 or sys.version_info.minor < 6:
|
||||
sys.stderr.write("Python 3.6 or later is required, but you have:\n" + sys.version + "\n")
|
||||
exit(1)
|
||||
print(os.pathsep)
|
||||
EOF
|
||||
)
|
||||
export PYTHONPATH=tools/mapmerge2/${PATHSEP}${PYTHONPATH}
|
||||
exec $PY "$@"
|
||||
|
||||
Reference in New Issue
Block a user