diff --git a/devShell.nix b/devShell.nix index 20839c59a..d3fcd9dd6 100644 --- a/devShell.nix +++ b/devShell.nix @@ -33,6 +33,7 @@ self'.packages.tea-create-pr self'.packages.merge-after-ci self'.packages.pending-reviews + self'.packages.agit # treefmt with config defined in ./flake-parts/formatting.nix config.treefmt.build.wrapper ]; diff --git a/pkgs/agit/README.md b/pkgs/agit/README.md new file mode 100644 index 000000000..058b4d55a --- /dev/null +++ b/pkgs/agit/README.md @@ -0,0 +1,37 @@ +# agit + +A helper script for the AGit workflow with a gitea instance. + + + +``` +usage: agit [-h] {create,c} ... + +AGit utility for creating and pulling PRs + +positional arguments: + {create,c} Commands + create (c) Create an AGit PR + +options: + -h, --help show this help message and exit + +The defaults that are assumed are: +TARGET_REMOTE_REPOSITORY = origin +DEFAULT_TARGET_BRANCH = main + +Examples: + $ agit create + Will create an AGit Pr with the latest commit message title as it's topic. + + $ agit create --topic "my-feature" + Set a custom topic. + + $ agit create --force + Force push to a certain topic + +``` + +References: +- https://docs.gitea.com/usage/agit +- https://git-repo.info/en/2020/03/agit-flow-and-git-repo/ diff --git a/pkgs/agit/agit.py b/pkgs/agit/agit.py new file mode 100644 index 000000000..ee6790f46 --- /dev/null +++ b/pkgs/agit/agit.py @@ -0,0 +1,205 @@ +import argparse +import subprocess +import sys + +# push origin HEAD:refs/for/main +# HEAD: The target branch +# origin: The target repository (not a fork!) +# HEAD: The local branch containing the changes you are proposing +TARGET_REMOTE_REPOSITORY = "origin" +DEFAULT_TARGET_BRANCH = "main" + + +def run_git_command(command: list) -> tuple[int, str, str]: + """Run a git command and return exit code, stdout, and stderr.""" + try: + result = subprocess.run(command, capture_output=True, text=True, check=False) + return result.returncode, result.stdout.strip(), result.stderr.strip() + except Exception as e: + return 1, "", str(e) + + +def get_latest_commit_info() -> tuple[str, str]: + """Get the title and body of the latest commit.""" + exit_code, commit_msg, error = run_git_command( + ["git", "log", "-1", "--pretty=format:%B"] + ) + + if exit_code != 0: + print(f"Error getting commit info: {error}") + sys.exit(1) + + lines = commit_msg.strip().split("\n") + title = lines[0].strip() if lines else "" + + body_lines = [] + for line in lines[1:]: + if body_lines or line.strip(): + body_lines.append(line) + + body = "\n".join(body_lines).strip() + + return title, body + + +def create_agit_push( + remote: str = "origin", + branch: str = "main", + topic: str | None = None, + title: str | None = None, + description: str | None = None, + force_push: bool = False, + local_branch: str = "HEAD", +) -> None: + if topic is None or title is None: + commit_title, _ = get_latest_commit_info() + + if topic is None: + topic = commit_title + if title is None: + title = commit_title + + refspec = f"{local_branch}:refs/for/{branch}" + push_cmd = ["git", "push", remote, refspec] + + push_cmd.extend(["-o", f"topic={topic}"]) + + if title: + push_cmd.extend(["-o", f"title={title}"]) + + if description: + escaped_desc = description.replace('"', '\\"') + push_cmd.extend(["-o", f"description={escaped_desc}"]) + + if force_push: + push_cmd.extend(["-o", "force-push"]) + + if description: + print( + f" Description: {description[:50]}..." + if len(description) > 50 + else f" Description: {description}" + ) + print() + + exit_code, stdout, stderr = run_git_command(push_cmd) + + if stdout: + print(stdout) + if stderr: + print(stderr, file=sys.stderr) + + if exit_code != 0: + print("\nPush failed!") + sys.exit(exit_code) + else: + print("\nPush successful!") + + +def cmd_create(args: argparse.Namespace) -> None: + """Handle the create subcommand.""" + create_agit_push( + remote=args.remote, + branch=args.branch, + topic=args.topic, + title=args.title, + description=args.description, + force_push=args.force, + local_branch=args.local_branch, + ) + + +def create_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="agit", + description="AGit utility for creating and pulling PRs", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=f""" +The defaults that are assumed are: +TARGET_REMOTE_REPOSITORY = {TARGET_REMOTE_REPOSITORY} +DEFAULT_TARGET_BRANCH = {DEFAULT_TARGET_BRANCH} + +Examples: + $ agit create + Will create an AGit Pr with the latest commit message title as it's topic. + + $ agit create --topic "my-feature" + Set a custom topic. + + $ agit create --force + Force push to a certain topic + """, + ) + + subparsers = parser.add_subparsers(dest="subcommand", help="Commands") + + create_parser = subparsers.add_parser( + "create", + aliases=["c"], + help="Create an AGit PR", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + $ agit create + Will create an AGit Pr with the latest commit message title as it's topic. + + $ agit create --topic "my-feature" + Set a custom topic. + + $ agit create --force + Force push to a certain topic + """, + ) + + create_parser.add_argument( + "-r", + "--remote", + default=TARGET_REMOTE_REPOSITORY, + help=f"Git remote to push to (default: {TARGET_REMOTE_REPOSITORY})", + ) + + create_parser.add_argument( + "-b", + "--branch", + default=DEFAULT_TARGET_BRANCH, + help=f"Target branch for the PR (default: {DEFAULT_TARGET_BRANCH})", + ) + + create_parser.add_argument( + "-l", + "--local-branch", + default="HEAD", + help="Local branch to push (default: HEAD)", + ) + + create_parser.add_argument( + "-t", "--topic", help="Set PR topic (default: last commit title)" + ) + + create_parser.add_argument( + "--title", help="Set the PR title (default: last commit title)" + ) + + create_parser.add_argument( + "--description", help="Override the PR description (default: commit body)" + ) + + create_parser.add_argument( + "-f", "--force", action="store_true", help="Force push the changes" + ) + + create_parser.set_defaults(func=cmd_create) + return parser + + +def main() -> None: + parser = create_parser() + args = parser.parse_args() + if args.subcommand is None: + parser.print_help() + sys.exit(0) + args.func(args) + + +if __name__ == "__main__": + main() diff --git a/pkgs/agit/default.nix b/pkgs/agit/default.nix new file mode 100644 index 000000000..f35654873 --- /dev/null +++ b/pkgs/agit/default.nix @@ -0,0 +1,26 @@ +{ + bash, + callPackage, + git, + lib, + openssh, + ... +}: +let + writers = callPackage ../builders/script-writers.nix { }; +in +writers.writePython3Bin "agit" { + flakeIgnore = [ + "E501" + ]; + makeWrapperArgs = [ + "--prefix" + "PATH" + ":" + (lib.makeBinPath [ + bash + git + openssh + ]) + ]; +} ./agit.py diff --git a/pkgs/flake-module.nix b/pkgs/flake-module.nix index 9ee5b69a5..fdfcb6a9f 100644 --- a/pkgs/flake-module.nix +++ b/pkgs/flake-module.nix @@ -17,6 +17,7 @@ { config, pkgs, ... }: { packages = { + agit = pkgs.callPackage ./agit { }; tea-create-pr = pkgs.callPackage ./tea-create-pr { }; zerotier-members = pkgs.callPackage ./zerotier-members { }; zt-tcp-relay = pkgs.callPackage ./zt-tcp-relay { };