tools tools tools!

This commit is contained in:
Letter N
2021-01-31 10:47:25 +08:00
parent 945599e937
commit 0901cf9f10
105 changed files with 1612 additions and 8467 deletions
+1 -1
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
@call "%~dp0\..\bootstrap\python" -m hooks.install --uninstall %*
@pause
Regular → Executable
+1 -1
View File
@@ -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
View File
@@ -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
+101
View File
@@ -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
View File
@@ -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 "$@"
+169
View File
@@ -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
View File
@@ -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
View File
@@ -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 "$@"