1113 lines
36 KiB
Python
1113 lines
36 KiB
Python
|
# sota staircase SideStepper
|
|||
|
# forge -> github one-way repo sync script
|
|||
|
#
|
|||
|
# Copyright (c) 2025 mark joshwel <mark@joshwel.co>
|
|||
|
#
|
|||
|
# Permission to use, copy, modify, and/or distribute this software for any
|
|||
|
# purpose with or without fee is hereby granted.
|
|||
|
#
|
|||
|
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|||
|
# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|||
|
# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
|
|||
|
# SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|||
|
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|||
|
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
|
|||
|
# IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
|||
|
|
|||
|
from json import loads as json_loads
|
|||
|
|
|||
|
# noinspection PyProtectedMember
|
|||
|
from multiprocessing.pool import ThreadPool
|
|||
|
from os import X_OK, access, cpu_count, getenv
|
|||
|
from pathlib import Path
|
|||
|
from platform import machine, system
|
|||
|
from pprint import pformat
|
|||
|
from shutil import copy2
|
|||
|
from subprocess import CompletedProcess
|
|||
|
from subprocess import run as _run
|
|||
|
from sys import argv, executable, stderr
|
|||
|
from tempfile import TemporaryDirectory
|
|||
|
from textwrap import indent
|
|||
|
from time import time
|
|||
|
from traceback import format_tb
|
|||
|
from typing import Callable, Final, TypeVar
|
|||
|
from urllib.request import urlopen
|
|||
|
|
|||
|
# constants
|
|||
|
INDENT: Final[str] = " "
|
|||
|
VERBOSE: Final[bool] = "--verbose" in argv
|
|||
|
REPO_URL_GITHUB: Final[str] = "github.com/markjoshwel/wirm"
|
|||
|
REPO_URL_FORGE: Final[str] = "forge.joshwel.co/mark/wirm"
|
|||
|
COMMIT_MESSAGE: Final[str] = "chore(restep): sync with forge"
|
|||
|
COMMIT_AUTHOR: Final[str] = "sota staircase ReStepper <ssrestepper@joshwel.co>"
|
|||
|
NEUTERED_GITATTRIBUTES: Final[str] = (
|
|||
|
"""# auto detect text files and perform lf normalization\n* text=auto\n"""
|
|||
|
)
|
|||
|
GH_ACT: Final[bool] = getenv("GITHUB_ACTIONS", "").lower() == "true"
|
|||
|
GH_TOKEN: Final[str] = getenv("SS_RESTEPPER_TOKEN", "")
|
|||
|
GH_USERNAME: Final[str] = "markjoshwel"
|
|||
|
if GH_ACT and GH_TOKEN == "":
|
|||
|
print(
|
|||
|
"error: no personal access token found in SS_RESTEPPER_TOKEN, "
|
|||
|
"may not have permission to push to github"
|
|||
|
)
|
|||
|
exit(1)
|
|||
|
_WORKERS = getenv("SS_WORKERS", None)
|
|||
|
CPU_COUNT = cpu_count()
|
|||
|
WORKERS: Final[int] = (
|
|||
|
int(_WORKERS)
|
|||
|
if ((_WORKERS is not None) and (_WORKERS.isdigit()))
|
|||
|
else 1
|
|||
|
if (CPU_COUNT is None)
|
|||
|
else CPU_COUNT
|
|||
|
)
|
|||
|
SIDESTEPPER_PRIMARY_LINK: Final[str] = (
|
|||
|
"https://forge.joshwel.co/api/v1/repos/mark/sidestepper/releases/latest"
|
|||
|
)
|
|||
|
SIDESTEPPER_SECONDARY_LINK: Final[str] = (
|
|||
|
"https://api.github.com/repos/markjoshwel/sidestepper/releases/latest"
|
|||
|
)
|
|||
|
SIDESTEPPER_GLOBAL_BINARY_PATH: Final[Path] = (
|
|||
|
Path()
|
|||
|
.home()
|
|||
|
.joinpath(
|
|||
|
".local/bin/sidestepper.exe"
|
|||
|
if (system().lower() == "windows")
|
|||
|
else ".local/bin/sidestepper"
|
|||
|
)
|
|||
|
)
|
|||
|
|
|||
|
|
|||
|
# dictionary to share state across steps
|
|||
|
r: dict[str, str] = {}
|
|||
|
|
|||
|
|
|||
|
# define these before importing third-party modules because we use them in the import check
|
|||
|
def generate_command_failure_message(cp: CompletedProcess) -> str:
|
|||
|
return "\n".join(
|
|||
|
[
|
|||
|
f"\n\nerror: command '{cp.args}' failed with exit code {cp.returncode}",
|
|||
|
f"{INDENT}stdout:",
|
|||
|
(
|
|||
|
indent(text=cp.stdout.decode(), prefix=f"{INDENT}{INDENT}")
|
|||
|
if (isinstance(cp.stdout, bytes) and (cp.stdout != b""))
|
|||
|
else f"{INDENT}{INDENT}(no output)"
|
|||
|
),
|
|||
|
f"{INDENT}stderr:",
|
|||
|
(
|
|||
|
indent(text=cp.stderr.decode(), prefix=f"{INDENT}{INDENT}")
|
|||
|
if (isinstance(cp.stderr, bytes) and (cp.stderr != b""))
|
|||
|
else f"{INDENT}{INDENT}(no output)"
|
|||
|
)
|
|||
|
+ "\n",
|
|||
|
]
|
|||
|
)
|
|||
|
|
|||
|
|
|||
|
def log_err(
|
|||
|
message: str | CompletedProcess,
|
|||
|
exception: Exception | None = None,
|
|||
|
condition: bool = True,
|
|||
|
exitcode: int | None = None,
|
|||
|
show_r: bool = False,
|
|||
|
) -> None:
|
|||
|
if not condition:
|
|||
|
return
|
|||
|
|
|||
|
if isinstance(message, CompletedProcess):
|
|||
|
print(
|
|||
|
generate_command_failure_message(message)
|
|||
|
+ (
|
|||
|
("\n" + indent(text=pformat(r), prefix=INDENT) + "\n") if show_r else ""
|
|||
|
),
|
|||
|
file=stderr,
|
|||
|
flush=True,
|
|||
|
)
|
|||
|
|
|||
|
elif isinstance(exception, Exception):
|
|||
|
print(
|
|||
|
f"\n\nerror: {exception}",
|
|||
|
f"{INDENT}{exception.__class__.__name__}: {exception}",
|
|||
|
indent(text=pformat(r), prefix=INDENT),
|
|||
|
indent(text="\n".join(format_tb(exception.__traceback__)), prefix=INDENT)
|
|||
|
+ (
|
|||
|
("\n" + indent(text=pformat(r), prefix=INDENT) + "\n") if show_r else ""
|
|||
|
),
|
|||
|
sep="\n",
|
|||
|
file=stderr,
|
|||
|
flush=True,
|
|||
|
)
|
|||
|
|
|||
|
else:
|
|||
|
print(
|
|||
|
f"error: {message}"
|
|||
|
+ (
|
|||
|
("\n" + indent(text=pformat(r), prefix=INDENT) + "\n") if show_r else ""
|
|||
|
),
|
|||
|
file=stderr,
|
|||
|
flush=True,
|
|||
|
)
|
|||
|
|
|||
|
if isinstance(exitcode, int):
|
|||
|
exit(exitcode)
|
|||
|
|
|||
|
|
|||
|
def log_debug(message: str) -> None:
|
|||
|
if VERBOSE:
|
|||
|
print(f"debug: {message}", file=stderr)
|
|||
|
|
|||
|
|
|||
|
def run(
|
|||
|
command: str | list,
|
|||
|
wd: Path | str | None = None,
|
|||
|
capture_output: bool = True,
|
|||
|
give_input: str | None = None,
|
|||
|
) -> CompletedProcess[bytes]:
|
|||
|
"""
|
|||
|
exception-safe-ish wrapper around subprocess.run()
|
|||
|
|
|||
|
args:
|
|||
|
command: str | list
|
|||
|
the command to run
|
|||
|
cwd: Path | str | None = None
|
|||
|
the working directory
|
|||
|
capture_output: bool = True
|
|||
|
whether to capture the output
|
|||
|
give_input: str | None = None
|
|||
|
the input to give to the command
|
|||
|
|
|||
|
returns: CompletedProcess
|
|||
|
the return object from subprocess.run()
|
|||
|
"""
|
|||
|
|
|||
|
# noinspection PyBroadException
|
|||
|
try:
|
|||
|
cp = _run(
|
|||
|
command,
|
|||
|
shell=False if isinstance(command, list) else True,
|
|||
|
cwd=wd,
|
|||
|
capture_output=capture_output,
|
|||
|
input=give_input.encode() if give_input else None,
|
|||
|
)
|
|||
|
except Exception as run_exc:
|
|||
|
log_err(f"command '{command}' failed with exception", run_exc)
|
|||
|
exit(-1)
|
|||
|
return cp
|
|||
|
|
|||
|
|
|||
|
# attempt to import third-party modules
|
|||
|
try:
|
|||
|
# noinspection PyUnresolvedReferences
|
|||
|
from tqdm import __version__ as tqdm_version
|
|||
|
# noinspection PyUnresolvedReferences
|
|||
|
from tqdm import tqdm
|
|||
|
|
|||
|
_tqdm_major, _tqdm_minor, _tqdm_patch = map(int, tqdm_version.split("."))
|
|||
|
if not ((4, 30, 0) <= (_tqdm_major, _tqdm_minor, _tqdm_patch)):
|
|||
|
log_err("unsupported tqdm version", condition=True)
|
|||
|
exit(-1)
|
|||
|
|
|||
|
except ImportError as _import_exc:
|
|||
|
log_err("tqdm is not installed, please run 'pip install tqdm' to install it")
|
|||
|
|
|||
|
# install the missing module(s)
|
|||
|
if input("install these with pip? y/n: ").lower() == "y":
|
|||
|
print("installing...", end="", flush=True)
|
|||
|
_cp = run([executable, "-m", "pip", "install", "tqdm"])
|
|||
|
if _cp.returncode != 0:
|
|||
|
print(generate_command_failure_message(_cp))
|
|||
|
exit(-1)
|
|||
|
print(" done", flush=True)
|
|||
|
|
|||
|
# check if it was installed successfully
|
|||
|
_cp = run(
|
|||
|
[
|
|||
|
executable,
|
|||
|
"-c",
|
|||
|
";".join(["import tqdm"]),
|
|||
|
]
|
|||
|
)
|
|||
|
if _cp.returncode != 0:
|
|||
|
log_err(_cp)
|
|||
|
print(
|
|||
|
"error: post-install check failed. reverting installation...",
|
|||
|
end="",
|
|||
|
flush=True,
|
|||
|
)
|
|||
|
_cp = run([executable, "-m", "pip", "uninstall", "tqdm", "-y"])
|
|||
|
log_err(_cp, condition=(_cp.returncode != 0))
|
|||
|
print(" done", flush=True)
|
|||
|
exit(-1)
|
|||
|
|
|||
|
# rerun the script if we're running as one
|
|||
|
exit(
|
|||
|
run(
|
|||
|
[executable, Path(__file__).absolute(), *argv[1:]], capture_output=False
|
|||
|
).returncode
|
|||
|
)
|
|||
|
|
|||
|
|
|||
|
class CopyHighway:
|
|||
|
"""
|
|||
|
multithreaded file copying class that gives a copy2-like function
|
|||
|
for use with shutil.copytree(); also displays a progress bar
|
|||
|
"""
|
|||
|
|
|||
|
pool: ThreadPool
|
|||
|
pbar: tqdm
|
|||
|
total: int
|
|||
|
respect_ignore: bool = True
|
|||
|
|
|||
|
def __init__(
|
|||
|
self,
|
|||
|
message: str,
|
|||
|
total: int,
|
|||
|
):
|
|||
|
"""
|
|||
|
multithreaded file copying class that gives a copy2-like function
|
|||
|
for use with shutil.copytree()
|
|||
|
|
|||
|
args:
|
|||
|
message: str
|
|||
|
message to display in the progress bar
|
|||
|
total: int
|
|||
|
total number of files to copy
|
|||
|
"""
|
|||
|
self.pool = ThreadPool(
|
|||
|
processes=WORKERS,
|
|||
|
)
|
|||
|
self.total = total
|
|||
|
self.pbar = tqdm(
|
|||
|
total=total,
|
|||
|
desc=message,
|
|||
|
unit=" files",
|
|||
|
leave=False,
|
|||
|
)
|
|||
|
|
|||
|
def callback(self, a: object):
|
|||
|
self.pbar.update()
|
|||
|
return a
|
|||
|
|
|||
|
def copy2(self, source: Path | str, dest: Path | str) -> None:
|
|||
|
"""shutil.copy2()-like function for use with shutil.copytree()"""
|
|||
|
self.pool.apply_async(copy2, args=(source, dest), callback=self.callback)
|
|||
|
|
|||
|
def __enter__(self):
|
|||
|
return self
|
|||
|
|
|||
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
|||
|
self.pool.close()
|
|||
|
self.pool.join()
|
|||
|
self.pbar.close()
|
|||
|
|
|||
|
|
|||
|
def generate_time_elapsed_string(time_taken: float) -> str:
|
|||
|
"""generates a human-readable time-elapsed string from a time-taken float"""
|
|||
|
hours = int(time_taken // 3600)
|
|||
|
minutes = int(time_taken % 3600 // 60)
|
|||
|
seconds = time_taken % 60
|
|||
|
if time_taken > 3600:
|
|||
|
return f"{hours}h {minutes}′ {seconds:.1f}″"
|
|||
|
elif time_taken > 60:
|
|||
|
return f"{minutes}′ {seconds:.3f}″"
|
|||
|
else:
|
|||
|
return f"{time_taken:.3f}″"
|
|||
|
|
|||
|
|
|||
|
def _sidestepper_resolve_binary_name() -> str:
|
|||
|
os: str
|
|||
|
match system().lower():
|
|||
|
case "windows":
|
|||
|
os = "windows"
|
|||
|
case "linux":
|
|||
|
os = "linux"
|
|||
|
case "darwin":
|
|||
|
os = "macos"
|
|||
|
case _:
|
|||
|
os = "unknown"
|
|||
|
|
|||
|
arch: str
|
|||
|
match machine().lower():
|
|||
|
case "x86_64":
|
|||
|
arch = "x86_64"
|
|||
|
case "amd64":
|
|||
|
arch = "x86_64"
|
|||
|
case "aarch64":
|
|||
|
arch = "arm64"
|
|||
|
case _:
|
|||
|
arch = "unknown"
|
|||
|
|
|||
|
match (os, arch):
|
|||
|
case ("unknown", _):
|
|||
|
return ""
|
|||
|
case (_, "unknown"):
|
|||
|
return ""
|
|||
|
case _:
|
|||
|
if os == "windows":
|
|||
|
return f"sidestepper-{os}-{arch}.exe"
|
|||
|
return f"sidestepper-{os}-{arch}"
|
|||
|
|
|||
|
|
|||
|
def _sidestepper_resolve_binary_path(root: Path | None) -> Path | str:
|
|||
|
"""returns path if found, empty string if not found, error message if error"""
|
|||
|
|
|||
|
sidestepper_binary_name = _sidestepper_resolve_binary_name()
|
|||
|
if sidestepper_binary_name == "":
|
|||
|
return "could not determine sidestepper binary name, your platform is probably unsupported"
|
|||
|
|
|||
|
if root:
|
|||
|
for possible_sidestepper in (
|
|||
|
root.joinpath(sidestepper_binary_name),
|
|||
|
root.joinpath(SIDESTEPPER_GLOBAL_BINARY_PATH.name),
|
|||
|
root.joinpath(f"Tooling/{sidestepper_binary_name}"),
|
|||
|
root.joinpath(f"Tooling/{SIDESTEPPER_GLOBAL_BINARY_PATH.name}"),
|
|||
|
):
|
|||
|
log_debug(
|
|||
|
f"_sidestepper_resolve_binary_path: trying to use '{possible_sidestepper}'"
|
|||
|
)
|
|||
|
if not possible_sidestepper.exists():
|
|||
|
continue
|
|||
|
if not possible_sidestepper.is_file():
|
|||
|
return f"'{possible_sidestepper}' is not a file, this should not happen"
|
|||
|
if not access(possible_sidestepper, X_OK):
|
|||
|
return f"'{possible_sidestepper}' is not executable, this should not happen"
|
|||
|
return possible_sidestepper
|
|||
|
|
|||
|
log_debug(
|
|||
|
f"_sidestepper_resolve_binary_path: trying to use '{SIDESTEPPER_GLOBAL_BINARY_PATH}'"
|
|||
|
)
|
|||
|
if not SIDESTEPPER_GLOBAL_BINARY_PATH.exists():
|
|||
|
return ""
|
|||
|
if not SIDESTEPPER_GLOBAL_BINARY_PATH.is_file():
|
|||
|
return f"'{SIDESTEPPER_GLOBAL_BINARY_PATH}' exists but is not a file, this should not happen"
|
|||
|
if not access(SIDESTEPPER_GLOBAL_BINARY_PATH, X_OK):
|
|||
|
return "'{SIDESTEPPER_GLOBAL_BINARY_PATH}' exists but is not executable, this should not happen"
|
|||
|
return SIDESTEPPER_GLOBAL_BINARY_PATH
|
|||
|
|
|||
|
|
|||
|
def _sidestepper_resolve_version_file() -> Path | str:
|
|||
|
"""
|
|||
|
creates parent directories if needed but not the file itself,
|
|||
|
returns the path if found, error message if error
|
|||
|
"""
|
|||
|
match system().lower():
|
|||
|
case "windows":
|
|||
|
version_path = (
|
|||
|
Path()
|
|||
|
.home()
|
|||
|
.joinpath(
|
|||
|
"AppData/Roaming/sota staircase/sidestepper/sidestepper.version.txt"
|
|||
|
)
|
|||
|
)
|
|||
|
case "linux":
|
|||
|
version_path = (
|
|||
|
Path()
|
|||
|
.home()
|
|||
|
.joinpath(".local/share/sotastaircase/sidestepper.version.txt")
|
|||
|
)
|
|||
|
case "darwin":
|
|||
|
version_path = (
|
|||
|
Path()
|
|||
|
.home()
|
|||
|
.joinpath(
|
|||
|
"Library/Application Support/co.joshwel.sotastaircase/sidestepper/sidestepper.version.txt"
|
|||
|
)
|
|||
|
)
|
|||
|
case _:
|
|||
|
version_path = (
|
|||
|
Path()
|
|||
|
.home()
|
|||
|
.joinpath(".local/share/sotastaircase/sidestepper.version.txt")
|
|||
|
)
|
|||
|
|
|||
|
if not version_path.exists():
|
|||
|
try:
|
|||
|
version_path.parent.mkdir(parents=True, exist_ok=True)
|
|||
|
except Exception as e:
|
|||
|
return f"could not create directory '{version_path.parent}' ({e.__class__.__name__}: {e})"
|
|||
|
|
|||
|
return version_path
|
|||
|
|
|||
|
|
|||
|
def _sidestepper_download_latest() -> str:
|
|||
|
"""used to download to the global binary path, returns an error message if error"""
|
|||
|
|
|||
|
# _sidestepper_resolve_binary_path() was called before this,
|
|||
|
# so we know the parent directories exist
|
|||
|
sidestepper_binary_name = _sidestepper_resolve_binary_name()
|
|||
|
if sidestepper_binary_name == "":
|
|||
|
return "could not determine sidestepper binary name, your platform is probably unsupported"
|
|||
|
sidestepper_version_path = _sidestepper_resolve_version_file()
|
|||
|
if isinstance(sidestepper_version_path, str):
|
|||
|
return sidestepper_version_path
|
|||
|
|
|||
|
version_tag: str = ""
|
|||
|
download_url: str = ""
|
|||
|
|
|||
|
# forge (gitea) and github have basically the same api
|
|||
|
for name, link in (
|
|||
|
("primary", SIDESTEPPER_PRIMARY_LINK),
|
|||
|
("secondary", SIDESTEPPER_SECONDARY_LINK),
|
|||
|
):
|
|||
|
try:
|
|||
|
with urlopen(link) as response_json:
|
|||
|
response_json = json_loads(response_json.read().decode("utf-8"))
|
|||
|
version_tag = response_json["tag_name"]
|
|||
|
for asset in response_json["assets"]:
|
|||
|
if asset["name"].lower() == sidestepper_binary_name.lower():
|
|||
|
download_url = asset["browser_download_url"]
|
|||
|
break
|
|||
|
else:
|
|||
|
continue
|
|||
|
log_debug(
|
|||
|
f"_sidestepper_download_latest: retrieval successful; using {name} api endpoint {link}"
|
|||
|
)
|
|||
|
break
|
|||
|
except Exception as e:
|
|||
|
print(
|
|||
|
f"warning: could not fetch latest sidestepper release from {name} api endpoint {link} ({e.__class__.__name__}: {e})",
|
|||
|
file=stderr,
|
|||
|
)
|
|||
|
|
|||
|
if (not version_tag) or (not download_url):
|
|||
|
return "could not fetch latest sidestepper release from any api endpoint"
|
|||
|
|
|||
|
# check if we already have the latest sidestepper release
|
|||
|
if sidestepper_version_path.exists():
|
|||
|
current_version_tag = sidestepper_version_path.read_text(
|
|||
|
encoding="utf-8"
|
|||
|
).strip()
|
|||
|
if (
|
|||
|
(current_version_tag == version_tag)
|
|||
|
and (SIDESTEPPER_GLOBAL_BINARY_PATH.exists())
|
|||
|
and (access(SIDESTEPPER_GLOBAL_BINARY_PATH, X_OK))
|
|||
|
):
|
|||
|
log_debug("_sidestepper_download_latest: nothing to do, returning early")
|
|||
|
return ""
|
|||
|
|
|||
|
# download the latest sidestepper release
|
|||
|
log_debug(
|
|||
|
f"_sidestepper_download_latest: downloading latest release from {download_url}"
|
|||
|
)
|
|||
|
try:
|
|||
|
SIDESTEPPER_GLOBAL_BINARY_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|||
|
with urlopen(download_url) as response:
|
|||
|
SIDESTEPPER_GLOBAL_BINARY_PATH.write_bytes(response.read())
|
|||
|
except Exception as e:
|
|||
|
return f"could not download latest sidestepper release {version_tag} from {download_url} to {SIDESTEPPER_GLOBAL_BINARY_PATH} ({e.__class__.__name__}: {e})"
|
|||
|
|
|||
|
# make it executable (non-windows)
|
|||
|
if not (system().lower() == "windows"):
|
|||
|
try:
|
|||
|
SIDESTEPPER_GLOBAL_BINARY_PATH.chmod(0o755)
|
|||
|
except Exception as e:
|
|||
|
return (
|
|||
|
f"could not make latest sidestepper release {version_tag} located at {SIDESTEPPER_GLOBAL_BINARY_PATH} executable ({e.__class__.__name__}: {e}) "
|
|||
|
f"- try running `chmod +x '{SIDESTEPPER_GLOBAL_BINARY_PATH}'`"
|
|||
|
)
|
|||
|
|
|||
|
# write the latest sidestepper version to the version file
|
|||
|
log_debug(
|
|||
|
f"_sidestepper_download_latest: writing latest sidestepper version {version_tag} to '{sidestepper_version_path}'"
|
|||
|
)
|
|||
|
try:
|
|||
|
sidestepper_version_path.parent.mkdir(parents=True, exist_ok=True)
|
|||
|
with open(sidestepper_version_path, "w") as version_file:
|
|||
|
version_file.write(version_tag)
|
|||
|
except Exception as e:
|
|||
|
return f"could not write latest sidestepper version {version_tag} to '{sidestepper_version_path}' ({e.__class__.__name__}: {e})"
|
|||
|
|
|||
|
return ""
|
|||
|
|
|||
|
|
|||
|
def sidestepper_get(root: Path | None) -> Path | str:
|
|||
|
"""returns a path if successfully retrieved, empty string if not found, error message if error"""
|
|||
|
|
|||
|
sidestepper: Path | str = _sidestepper_resolve_binary_path(root)
|
|||
|
log_debug(f"sidestepper_get: resolved binary path: '{sidestepper}'")
|
|||
|
|
|||
|
if isinstance(sidestepper, Path):
|
|||
|
if root and (str(root.absolute()) in str(sidestepper.absolute())):
|
|||
|
# we are using a local sidestepper binary (in the repo)
|
|||
|
return sidestepper
|
|||
|
|
|||
|
# we are using a global sidestepper binary (in ~/.local/bin)
|
|||
|
# let's try to update it
|
|||
|
log_debug("sidestepper_get: attempting update")
|
|||
|
dl_err = _sidestepper_download_latest()
|
|||
|
if dl_err:
|
|||
|
print(
|
|||
|
f"warning: tried to update sidestepper but failed, sweeping under the rug and continuing ({dl_err})",
|
|||
|
file=stderr,
|
|||
|
)
|
|||
|
|
|||
|
return sidestepper
|
|||
|
|
|||
|
# _sidestepper_resolve_binary_path returned an error string,
|
|||
|
# and it is not empty, propagate it up to the caller to print
|
|||
|
if isinstance(sidestepper, str) and sidestepper:
|
|||
|
return sidestepper
|
|||
|
|
|||
|
# so let's download the latest sidestepper binary
|
|||
|
log_debug("sidestepper_get: downloading latest")
|
|||
|
dl_err = _sidestepper_download_latest()
|
|||
|
if dl_err:
|
|||
|
return dl_err
|
|||
|
return SIDESTEPPER_GLOBAL_BINARY_PATH
|
|||
|
|
|||
|
|
|||
|
def get_root() -> Path | None:
|
|||
|
"""finds the root of the git repository, returns None if not found"""
|
|||
|
root: Path = Path().cwd().resolve()
|
|||
|
while not (root.joinpath(".git").exists()):
|
|||
|
root = root.parent
|
|||
|
if root == Path("/").resolve():
|
|||
|
return None
|
|||
|
return root
|
|||
|
|
|||
|
|
|||
|
R = TypeVar("R")
|
|||
|
|
|||
|
|
|||
|
def _default_post_func(cp: R) -> R:
|
|||
|
"""
|
|||
|
default post-call function for steps; does nothing
|
|||
|
|
|||
|
for steps that return a CompletedProcess, this function will run the
|
|||
|
`_command_post_func` function
|
|||
|
|
|||
|
args:
|
|||
|
cp: R
|
|||
|
return object from a step function
|
|||
|
|
|||
|
returns: R
|
|||
|
the return object from the step function
|
|||
|
"""
|
|||
|
if isinstance(cp, CompletedProcess):
|
|||
|
_command_post_func(cp)
|
|||
|
return cp
|
|||
|
|
|||
|
|
|||
|
def _command_post_func(
|
|||
|
cp: CompletedProcess,
|
|||
|
fail_on_error: bool = True,
|
|||
|
quit_early: bool = False,
|
|||
|
quit_message: str = "the command gave unexpected output",
|
|||
|
) -> CompletedProcess:
|
|||
|
"""
|
|||
|
default post-call function for command steps; checks if the command was
|
|||
|
successful and prints the output if it wasn't
|
|||
|
|
|||
|
if the command was successful, the stdout and stderr are stored in the
|
|||
|
shared state dictionary r under 'stdout' and 'stderr' respectively
|
|||
|
|
|||
|
args:
|
|||
|
cp: CompletedProcess
|
|||
|
return object from subprocess.run()
|
|||
|
fail_on_error: bool
|
|||
|
whether to fail on error
|
|||
|
quit_early: bool
|
|||
|
whether to quit early
|
|||
|
quit_message: str
|
|||
|
the message to print if quitting early
|
|||
|
|
|||
|
returns: CompletedProcess
|
|||
|
the return object from subprocess.run()
|
|||
|
"""
|
|||
|
|
|||
|
if quit_early:
|
|||
|
print(f"\n\nfailure: {quit_message}\n")
|
|||
|
|
|||
|
else:
|
|||
|
r["stdout"] = cp.stdout.decode() if isinstance(cp.stdout, bytes) else "\0"
|
|||
|
r["stderr"] = cp.stderr.decode() if isinstance(cp.stderr, bytes) else "\0"
|
|||
|
r["blank/stdout"] = "yes" if (r["stdout"].strip() == "") else ""
|
|||
|
r["blank/stderr"] = "yes" if (r["stderr"].strip() == "") else ""
|
|||
|
r["blank"] = "yes" if (r["blank/stdout"] and r["blank/stderr"]) else ""
|
|||
|
r["errored"] = "" if (cp.returncode == 0) else str(cp.returncode)
|
|||
|
|
|||
|
# return if the command was successful
|
|||
|
# or if we're not failing on error
|
|||
|
if (cp.returncode == 0) or (not fail_on_error):
|
|||
|
return cp
|
|||
|
else:
|
|||
|
print(generate_command_failure_message(cp))
|
|||
|
|
|||
|
exit(
|
|||
|
cp.returncode if (isinstance(cp.returncode, int) and cp.returncode != 0) else 1
|
|||
|
)
|
|||
|
|
|||
|
|
|||
|
def step(
|
|||
|
func: Callable[[], R],
|
|||
|
desc: str = "",
|
|||
|
post_func: Callable[[R], R] = _default_post_func,
|
|||
|
post_print: bool = True,
|
|||
|
reprint: bool = False,
|
|||
|
exitcode: int = -1,
|
|||
|
) -> R:
|
|||
|
"""
|
|||
|
helper function for running steps
|
|||
|
|
|||
|
args:
|
|||
|
desc: str
|
|||
|
description of the step
|
|||
|
func: Callable[[], R] = ""
|
|||
|
function to run
|
|||
|
post_func: Callable[[R], R] = _default_post_func
|
|||
|
post-function to run after func
|
|||
|
post_print: bool = True
|
|||
|
whether to print done after the step
|
|||
|
reprint: bool = False
|
|||
|
whether to reprint description when done,
|
|||
|
for progress bars that override line output
|
|||
|
|
|||
|
returns:
|
|||
|
R
|
|||
|
return object from func
|
|||
|
"""
|
|||
|
|
|||
|
# run the function
|
|||
|
if desc != "":
|
|||
|
print(f"{desc}..", end="", flush=True)
|
|||
|
|
|||
|
start_time = time()
|
|||
|
|
|||
|
try:
|
|||
|
result = func()
|
|||
|
|
|||
|
except Exception as exc:
|
|||
|
print(
|
|||
|
f"\n\nfailure running step: {exc} ({exc.__class__.__name__})",
|
|||
|
"\n".join(format_tb(exc.__traceback__)) + "\n",
|
|||
|
sep="\n",
|
|||
|
)
|
|||
|
exit(exitcode)
|
|||
|
|
|||
|
if desc != "" and not reprint:
|
|||
|
print(".", end="", flush=True)
|
|||
|
|
|||
|
# run the post-function
|
|||
|
try:
|
|||
|
rp = post_func(result)
|
|||
|
|
|||
|
except Exception as exc:
|
|||
|
print(
|
|||
|
f"\n\nfailure running post-step: {exc} ({exc.__class__.__name__})",
|
|||
|
"\n".join(format_tb(exc.__traceback__)) + "\n",
|
|||
|
sep="\n",
|
|||
|
)
|
|||
|
exit(exitcode)
|
|||
|
|
|||
|
end_time = time()
|
|||
|
|
|||
|
# yay
|
|||
|
if desc != "" and post_print:
|
|||
|
print(
|
|||
|
(f"{desc}..." if reprint else "")
|
|||
|
+ f" done in {generate_time_elapsed_string(end_time - start_time)}",
|
|||
|
flush=True,
|
|||
|
)
|
|||
|
|
|||
|
return rp
|
|||
|
|
|||
|
|
|||
|
def post_remote_v(cp: CompletedProcess) -> CompletedProcess:
|
|||
|
"""
|
|||
|
post-call function for 'git remote -v' command, parses the output and
|
|||
|
checks for the forge and github remotes, storing them in the shared state
|
|||
|
under 'remote/forge', 'remote/forge/url', 'remote/github', and
|
|||
|
'remote/github/url' respectively
|
|||
|
"""
|
|||
|
|
|||
|
if not isinstance(cp.stdout, bytes):
|
|||
|
return _command_post_func(cp)
|
|||
|
|
|||
|
for line in cp.stdout.decode().split("\n"):
|
|||
|
# github https://github.com/markjoshwel/sota (fetch)
|
|||
|
# github https://github.com/markjoshwel/sota (push)
|
|||
|
# origin https://forge.joshwel.co/mark/sota.git (fetch)
|
|||
|
# origin https://forge.joshwel.co/mark/sota.git (push)
|
|||
|
|
|||
|
split_line = line.split(maxsplit=1)
|
|||
|
if len(line) < 2:
|
|||
|
continue
|
|||
|
|
|||
|
# remote='origin' url='https://forge.joshwel.co/mark/sota.git (fetch)'
|
|||
|
remote, url = split_line
|
|||
|
|
|||
|
# clean up the url
|
|||
|
if (REPO_URL_FORGE in url) or (REPO_URL_GITHUB in url):
|
|||
|
# url='https://forge.joshwel.co/mark/sota.git'
|
|||
|
url = url.split("(", maxsplit=1)[0].strip()
|
|||
|
|
|||
|
if REPO_URL_FORGE in url:
|
|||
|
r["remote/forge"] = remote
|
|||
|
r["remote/forge/url"] = url
|
|||
|
|
|||
|
elif REPO_URL_GITHUB in url:
|
|||
|
r["remote/github"] = remote
|
|||
|
r["remote/github/url"] = url
|
|||
|
|
|||
|
return _command_post_func(cp)
|
|||
|
|
|||
|
|
|||
|
def post_filter_repo_check(cp: CompletedProcess) -> CompletedProcess:
|
|||
|
"""
|
|||
|
post-call function for checking if git-filter-repo is installed
|
|||
|
and optionally installing it if it isn't
|
|||
|
"""
|
|||
|
|
|||
|
if cp.returncode == 0:
|
|||
|
return cp
|
|||
|
|
|||
|
if input("git filter-repo is not installed, install it? y/n: ").lower() != "y":
|
|||
|
print(
|
|||
|
"install it using 'pip install git-filter-repo' "
|
|||
|
"or 'pipx install git-filter-repo'",
|
|||
|
)
|
|||
|
return cp
|
|||
|
|
|||
|
# check if pipx is installed
|
|||
|
use_pipx = False
|
|||
|
|
|||
|
check_pipx_cp = run(["pipx", "--version"])
|
|||
|
if check_pipx_cp.returncode == 0:
|
|||
|
use_pipx = True
|
|||
|
|
|||
|
# install git-filter-repo
|
|||
|
pip_invocation: list[str] = ["pipx"] if use_pipx else [executable, "-m", "pip"]
|
|||
|
print(
|
|||
|
f"running '{' '.join([*pip_invocation, 'install', 'git-filter-repo'])}'... ",
|
|||
|
end="",
|
|||
|
flush=True,
|
|||
|
)
|
|||
|
install_rc = run([*pip_invocation, "install", "git-filter-repo"])
|
|||
|
if install_rc.returncode != 0:
|
|||
|
print("error")
|
|||
|
_command_post_func(install_rc)
|
|||
|
exit(install_rc.returncode)
|
|||
|
else:
|
|||
|
print("done\n")
|
|||
|
|
|||
|
# check if it is reachable
|
|||
|
if run(["git", "filter-repo", "--version"]).returncode != 0:
|
|||
|
# revert
|
|||
|
run([*pip_invocation, "uninstall", "git-filter-repo"])
|
|||
|
print(
|
|||
|
"failure: could not install git-filter-repo automatically. "
|
|||
|
"do it yourself o(*≧▽≦)ツ┏━┓"
|
|||
|
)
|
|||
|
exit(-1)
|
|||
|
|
|||
|
return cp
|
|||
|
|
|||
|
|
|||
|
def main() -> None:
|
|||
|
repo_path = get_root()
|
|||
|
log_err(
|
|||
|
"could not find a git repository in the working or parent directories",
|
|||
|
condition=not isinstance(repo_path, Path),
|
|||
|
exitcode=1,
|
|||
|
)
|
|||
|
assert isinstance(repo_path, Path)
|
|||
|
|
|||
|
sidestepper_binary = sidestepper_get(repo_path)
|
|||
|
log_err(
|
|||
|
"could not find sidestepper binary",
|
|||
|
condition=(sidestepper_binary == ""),
|
|||
|
exitcode=2,
|
|||
|
)
|
|||
|
log_err(
|
|||
|
sidestepper_binary, # type: ignore
|
|||
|
condition=isinstance(sidestepper_binary, str),
|
|||
|
exitcode=3,
|
|||
|
)
|
|||
|
log_debug(f"sidestepper binary is '{sidestepper_binary}'")
|
|||
|
assert isinstance(sidestepper_binary, Path)
|
|||
|
|
|||
|
cumulative_start_time = time()
|
|||
|
with TemporaryDirectory(delete="--keep" not in argv) as temp_dir:
|
|||
|
temp_path = Path(temp_dir)
|
|||
|
print(
|
|||
|
"sota staircase ReStepper v12",
|
|||
|
f" real repo : {repo_path}",
|
|||
|
f" temp repo : {temp_dir}",
|
|||
|
f" sidestepper : {sidestepper_binary}"
|
|||
|
+ (f"\n is gh act : {GH_ACT}\n" if GH_ACT else "\n"),
|
|||
|
sep="\n",
|
|||
|
)
|
|||
|
|
|||
|
# helper partial function for command
|
|||
|
def cmd(
|
|||
|
command: str,
|
|||
|
wd: Path | str = temp_dir,
|
|||
|
capture_output: bool = True,
|
|||
|
give_input: str | None = None,
|
|||
|
) -> Callable[[], CompletedProcess]:
|
|||
|
return lambda: run(
|
|||
|
command,
|
|||
|
wd=wd,
|
|||
|
capture_output=capture_output,
|
|||
|
give_input=give_input,
|
|||
|
)
|
|||
|
|
|||
|
log_debug("checking for git filter-repo")
|
|||
|
step(
|
|||
|
func=cmd("git filter-repo --version"),
|
|||
|
post_func=post_filter_repo_check,
|
|||
|
exitcode=4,
|
|||
|
)
|
|||
|
|
|||
|
log_debug("checking git status")
|
|||
|
step(func=cmd("git status --porcelain", wd=repo_path), exitcode=5)
|
|||
|
if (not r["blank"]) and ("--idonotcare" not in argv):
|
|||
|
log_err(
|
|||
|
"repository is not clean, please commit changes first",
|
|||
|
show_r=True,
|
|||
|
exitcode=5,
|
|||
|
)
|
|||
|
|
|||
|
step(
|
|||
|
desc="1 pre | finding large files",
|
|||
|
func=cmd(f"{sidestepper_binary}", wd=repo_path),
|
|||
|
exitcode=6,
|
|||
|
)
|
|||
|
if r["errored"]:
|
|||
|
log_err(
|
|||
|
"could not find large files",
|
|||
|
show_r=True,
|
|||
|
exitcode=6,
|
|||
|
)
|
|||
|
|
|||
|
def get_repo_files() -> list[Path]:
|
|||
|
files: list[Path] = []
|
|||
|
for file in run("git ls-files", wd=repo_path).stdout.decode().splitlines():
|
|||
|
file = file.strip()
|
|||
|
if not file:
|
|||
|
continue
|
|||
|
files.append(Path(file))
|
|||
|
for src in repo_path.joinpath(".git").rglob("*"):
|
|||
|
files.append(Path(src).relative_to(repo_path))
|
|||
|
# no .sotaignore because git filter-repo might remove
|
|||
|
# it if it wasn't already in history
|
|||
|
return files
|
|||
|
|
|||
|
repo_files = step(
|
|||
|
desc="2 pre | scanning repo",
|
|||
|
func=lambda: get_repo_files(),
|
|||
|
exitcode=7,
|
|||
|
)
|
|||
|
|
|||
|
def duplicate_repo():
|
|||
|
with CopyHighway(
|
|||
|
message="3 pre | duplicating repo",
|
|||
|
total=len(repo_files),
|
|||
|
) as copier:
|
|||
|
for src in repo_files:
|
|||
|
dst = temp_path.joinpath(src)
|
|||
|
if not dst.parent.exists():
|
|||
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
|||
|
copier.copy2(repo_path.joinpath(src), dst)
|
|||
|
|
|||
|
step(
|
|||
|
desc="3 pre | duplicate repo",
|
|||
|
func=lambda: duplicate_repo(),
|
|||
|
reprint=True,
|
|||
|
exitcode=8,
|
|||
|
)
|
|||
|
|
|||
|
log_debug("double-checking pathlib.Path.cwd()")
|
|||
|
step(
|
|||
|
func=cmd(
|
|||
|
'python -c "import pathlib; print(pathlib.Path.cwd().absolute())"'
|
|||
|
),
|
|||
|
exitcode=9,
|
|||
|
)
|
|||
|
if str(temp_path.absolute()) != r["stdout"].strip():
|
|||
|
log_err(
|
|||
|
f"not inside the temp dir '{str(temp_path.absolute())}' (whuh? internal?)",
|
|||
|
show_r=True,
|
|||
|
exitcode=9,
|
|||
|
)
|
|||
|
|
|||
|
log_debug("checking for forge and github remotes")
|
|||
|
step(
|
|||
|
func=cmd("git remote -v"),
|
|||
|
post_func=post_remote_v,
|
|||
|
exitcode=10,
|
|||
|
)
|
|||
|
if "remote/forge" not in r:
|
|||
|
log_err(
|
|||
|
"no forge remote found (whuh?)",
|
|||
|
show_r=True,
|
|||
|
exitcode=10,
|
|||
|
)
|
|||
|
|
|||
|
log_debug("getting the current branch")
|
|||
|
step(func=cmd("git branch --show-current"), exitcode=11)
|
|||
|
branch = r["stdout"].strip()
|
|||
|
if r.get("errored", "yes") or branch == "":
|
|||
|
log_err("couldn't get current branch (whuh?)", show_r=True, exitcode=11)
|
|||
|
|
|||
|
log_debug("checking if up to date with forge: fetching")
|
|||
|
step(func=cmd(f"git fetch {r['remote/forge']}"), exitcode=12)
|
|||
|
|
|||
|
log_debug(
|
|||
|
f"checking if up to date with forge: rev-list HEAD...{r['remote/forge']}/{branch} count"
|
|||
|
)
|
|||
|
step(
|
|||
|
func=cmd(f"git rev-list HEAD...{r['remote/forge']}/{branch} --count"),
|
|||
|
exitcode=13,
|
|||
|
)
|
|||
|
if (r.get("stdout", "").strip() != "0") and ("--idonotcare" not in argv):
|
|||
|
log_err(
|
|||
|
"not up to date with forge... sync your changes first? (whuh?)",
|
|||
|
show_r=True,
|
|||
|
exitcode=13,
|
|||
|
)
|
|||
|
|
|||
|
step(desc="4 lfs | fetch lfs objects", func=cmd("git lfs fetch"), exitcode=14)
|
|||
|
|
|||
|
step(
|
|||
|
desc="5 lfs | migrating lfs objects",
|
|||
|
func=cmd(
|
|||
|
f'git lfs migrate export --everything --include="*" --remote={r["remote/forge"]} --yes',
|
|||
|
# give_input="y\n",
|
|||
|
),
|
|||
|
exitcode=15,
|
|||
|
)
|
|||
|
|
|||
|
step(
|
|||
|
desc="6 lfs | uninstall lfs in repo",
|
|||
|
func=cmd("git lfs uninstall"),
|
|||
|
exitcode=16,
|
|||
|
)
|
|||
|
|
|||
|
log_debug("checking if lfs objects still exist")
|
|||
|
step(
|
|||
|
func=cmd("git lfs ls-files"),
|
|||
|
exitcode=17,
|
|||
|
)
|
|||
|
if not r["blank"]:
|
|||
|
log_err(
|
|||
|
"critical error (whuh? internal?): "
|
|||
|
"lfs objects still exist post-migrate and uninstall",
|
|||
|
show_r=True,
|
|||
|
exitcode=18,
|
|||
|
)
|
|||
|
|
|||
|
if (repo_sotaignore_path := repo_path.joinpath(".sotaignore")).exists():
|
|||
|
log_debug(".sotaignore exists, reading")
|
|||
|
|
|||
|
sotaignore: str = ""
|
|||
|
try:
|
|||
|
sotaignore = repo_sotaignore_path.read_text(encoding="utf-8").strip()
|
|||
|
except Exception as exc:
|
|||
|
log_err(
|
|||
|
"couldn't read .sotaignore file",
|
|||
|
exception=exc,
|
|||
|
exitcode=19,
|
|||
|
)
|
|||
|
|
|||
|
sotaignored_files: list[str] = [
|
|||
|
line
|
|||
|
for line in sotaignore.splitlines()
|
|||
|
if not line.startswith("#") and line.strip() != ""
|
|||
|
]
|
|||
|
log_debug(f"found {len(sotaignored_files)} file(s) in .sotaignore")
|
|||
|
|
|||
|
step(
|
|||
|
desc=f"7 lfs | filter repo and {len(sotaignored_files)} file(s)",
|
|||
|
func=cmd(
|
|||
|
"git filter-repo --force --strip-blobs-bigger-than 100M --invert-paths "
|
|||
|
+ " ".join(f'--path "{lf}"' for lf in sotaignored_files)
|
|||
|
),
|
|||
|
exitcode=20,
|
|||
|
)
|
|||
|
|
|||
|
log_debug(
|
|||
|
"copying .sotaignore to temp repo; step 5 wipes uncommitted changes"
|
|||
|
)
|
|||
|
step(
|
|||
|
func=lambda: copy2(
|
|||
|
repo_sotaignore_path, Path(temp_path).joinpath(".sotaignore")
|
|||
|
),
|
|||
|
exitcode=21,
|
|||
|
)
|
|||
|
|
|||
|
def neuter_and_commit() -> CompletedProcess:
|
|||
|
# neuter
|
|||
|
for repo_file in temp_path.rglob(".gitattributes"):
|
|||
|
repo_file.write_text(NEUTERED_GITATTRIBUTES, encoding="utf-8")
|
|||
|
|
|||
|
# add
|
|||
|
if GH_ACT:
|
|||
|
cp = cmd("git config user.name 'github-actions[bot]'")()
|
|||
|
if cp.returncode != 0:
|
|||
|
return cp
|
|||
|
|
|||
|
cp = cmd(
|
|||
|
"git config user.email 'github-actions[bot]@users.noreply.github.com'"
|
|||
|
)()
|
|||
|
if cp.returncode != 0:
|
|||
|
return cp
|
|||
|
|
|||
|
cp = cmd("git add -A")()
|
|||
|
if cp.returncode != 0:
|
|||
|
return cp
|
|||
|
|
|||
|
# and commit
|
|||
|
return cmd(
|
|||
|
f'git commit --allow-empty -am "{COMMIT_MESSAGE}" --author="{COMMIT_AUTHOR}"',
|
|||
|
)()
|
|||
|
|
|||
|
step(
|
|||
|
desc="8 fin | neuter .gitattributes and commit",
|
|||
|
func=neuter_and_commit,
|
|||
|
exitcode=22,
|
|||
|
)
|
|||
|
|
|||
|
if r.get("remote/github") is None:
|
|||
|
log_debug(f"github remote doesn't exist, adding via {REPO_URL_GITHUB=}")
|
|||
|
step(
|
|||
|
func=cmd(f"git remote add github https://{REPO_URL_GITHUB}.git"),
|
|||
|
exitcode=23,
|
|||
|
)
|
|||
|
if r.get("errored", "yes"):
|
|||
|
log_err(
|
|||
|
"couldn't add github remote",
|
|||
|
show_r=True,
|
|||
|
exitcode=23,
|
|||
|
)
|
|||
|
r["remote/github"] = "github"
|
|||
|
|
|||
|
step(
|
|||
|
desc=f"9 fin | fetch {r['remote/github']}",
|
|||
|
func=cmd(f"git fetch {r['remote/github']}"),
|
|||
|
exitcode=24,
|
|||
|
)
|
|||
|
|
|||
|
push_invocation = (
|
|||
|
f"git push {r['remote/github']} {branch} --force"
|
|||
|
if not GH_ACT
|
|||
|
else f"git push https://{GH_USERNAME}:{GH_TOKEN}@{REPO_URL_GITHUB}.git {branch} --force"
|
|||
|
)
|
|||
|
|
|||
|
step(
|
|||
|
desc=f"X fin | pushing to {r['remote/github']}/{branch}",
|
|||
|
func=cmd(push_invocation if ("--test" not in argv) else "git --version"),
|
|||
|
exitcode=25,
|
|||
|
)
|
|||
|
|
|||
|
cumulative_end_time = time()
|
|||
|
print(
|
|||
|
f"\n--- done! took {generate_time_elapsed_string(cumulative_end_time - cumulative_start_time)}~ "
|
|||
|
"☆*: .。. o(≧▽≦)o .。.:*☆ ---",
|
|||
|
flush=True,
|
|||
|
)
|
|||
|
|
|||
|
|
|||
|
if __name__ == "__main__":
|
|||
|
main()
|