summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rwxr-xr-xcreate_remote_repo.sh106
-rw-r--r--git_merge_report.py217
3 files changed, 324 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..727bc05
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+.directory
diff --git a/create_remote_repo.sh b/create_remote_repo.sh
new file mode 100755
index 0000000..389c09d
--- /dev/null
+++ b/create_remote_repo.sh
@@ -0,0 +1,106 @@
+#!/usr/bin/env bash
+#
+# create_remote_repo.sh
+#
+# Creates a bare Git repository on a remote server (under /var/git, or
+# /var/git-private if --p is passed) via SSH, then adds it as a remote for
+# the current local repo (under the name you choose) and pushes the current
+# branch to it.
+#
+# Usage:
+# ./create_remote_repo.sh [--p] <repo-name> <remote-name>
+#
+# Flags:
+# --p Create the bare repo under /var/git-private instead of /var/git
+#
+# Example:
+# ./create_remote_repo.sh my-project origin
+# -> creates /var/git/my-project.git on the "sourceobby" host
+# -> adds it locally as remote "origin"
+#
+# ./create_remote_repo.sh --p my-secret-project origin
+# -> creates /var/git-private/my-secret-project.git on the "sourceobby" host
+# -> adds it locally as remote "origin"
+#
+# Requirements:
+# - An SSH host alias called "sourceobby" already configured
+# (e.g. in ~/.ssh/config), pointing at the correct IP/user.
+# - The remote account used by "sourceobby" must be able to run
+# `sudo -u git` (passwordless sudo recommended, otherwise you'll be
+# prompted for a sudo password on the remote host).
+# - A 'git' user/account must already exist on the remote server.
+# - Run this script from inside the local git repo you want to push.
+#
+# Note on passwords:
+# This script does NOT store or pass your SSH password anywhere. It simply
+# invokes `ssh`/`git push` normally, so if your "sourceobby" alias is set
+# up for password authentication, you'll be prompted interactively by SSH
+# itself, once for the remote setup and once for the push.
+
+set -euo pipefail
+
+# ---- Argument handling ----
+PRIVATE=false
+POSITIONAL=()
+
+for arg in "$@"; do
+ case "$arg" in
+ --p)
+ PRIVATE=true
+ ;;
+ --)
+ ;;
+ *)
+ POSITIONAL+=("$arg")
+ ;;
+ esac
+done
+
+if [ "${#POSITIONAL[@]}" -ne 2 ]; then
+ echo "Usage: $0 [--p] <repo-name> <remote-name>" >&2
+ exit 1
+fi
+
+REPO_NAME="${POSITIONAL[0]}"
+REMOTE_NAME="${POSITIONAL[1]}"
+REMOTE_ALIAS="git.sourceobby"
+
+if [ "${PRIVATE}" = true ]; then
+ BASE_DIR="/var/git-private"
+else
+ BASE_DIR="/var/git"
+fi
+
+REMOTE_DIR="${BASE_DIR}/${REPO_NAME}.git"
+REMOTE_URL="${REMOTE_ALIAS}:${REMOTE_DIR}"
+
+# ---- Sanity check: are we inside a git repo? ----
+if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
+ echo "Error: this script must be run from inside a local git repository." >&2
+ exit 1
+fi
+
+# ---- Step 1: Create the bare repo on the remote server (as the 'git' user) ----
+# Running as the 'git' user ensures the new repo is owned by the right
+# account and has correct permissions, regardless of which user/account the
+# "sourceobby" SSH alias logs in as.
+echo "Creating bare repo on ${REMOTE_ALIAS}:${REMOTE_DIR} (as user 'git') ..."
+echo "(You may be prompted for your SSH password, and possibly a sudo password.)"
+ssh -t "${REMOTE_ALIAS}" "mkdir -p '${REMOTE_DIR}' && git init --bare '${REMOTE_DIR}'"
+
+# ---- Step 2: Add (or update) the local remote ----
+if git remote get-url "${REMOTE_NAME}" >/dev/null 2>&1; then
+ echo "Remote '${REMOTE_NAME}' already exists locally; updating its URL..."
+ git remote set-url "${REMOTE_NAME}" "${REMOTE_URL}"
+else
+ echo "Adding '${REMOTE_NAME}' remote -> ${REMOTE_URL}"
+ git remote add "${REMOTE_NAME}" "${REMOTE_URL}"
+fi
+
+# ---- Step 3: Push the current branch ----
+CURRENT_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
+echo "Pushing branch '${CURRENT_BRANCH}' to ${REMOTE_NAME} ..."
+echo "(You may be prompted for your SSH password again.)"
+git push -u "${REMOTE_NAME}" "${CURRENT_BRANCH}"
+
+echo "Done. Remote repo '${REPO_NAME}.git' created and local repo pushed."
diff --git a/git_merge_report.py b/git_merge_report.py
new file mode 100644
index 0000000..c351ece
--- /dev/null
+++ b/git_merge_report.py
@@ -0,0 +1,217 @@
+#!/usr/bin/env python3
+"""
+git_merge_report.py
+
+Merges a remote branch into a local branch, and builds a summary report of
+the merge (divergence point, commit count, line changes, file changes).
+The rendered summary is used as the merge commit message.
+
+Usage:
+ python git_merge_report.py <local_branch> <remote_branch> (-m "message" | -f path/to/message.txt)
+
+Examples:
+ python git_merge_report.py main feature/login -m "Add login flow"
+ python git_merge_report.py main feature/login -f commit_msg.txt
+ python git_merge_report.py main feature/login -m "Add login flow" --dry-run
+"""
+
+import argparse
+import re
+import subprocess
+import sys
+from datetime import datetime, timezone
+
+TEMPLATE = """Merge {remote} into {local}:
+Merge commit message:
+{msg}
+
+-----------------------------BEGIN SUMMARY OF MERGE----------------------------
+Merging {n} commits into {local}.
+{remote} diverged from {local} at commit {commit_hash} {days} days ago
+Commit makes {add_chg} additions, {rem_chg} deletions
+Commit adds {add_file} files, removes {rem_file} files
+------------------------------END SUMMARY OF MERGE-----------------------------
+"""
+
+
+class GitError(RuntimeError):
+ pass
+
+
+def run_git(args, repo="."):
+ """Run a git command and return stripped stdout. Raises GitError on failure."""
+ try:
+ result = subprocess.run(
+ ["git", "-C", repo] + args,
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+ return result.stdout.strip()
+ except subprocess.CalledProcessError as e:
+ raise GitError(
+ f"git {' '.join(args)} failed:\n{e.stderr.strip()}"
+ ) from e
+ except FileNotFoundError as e:
+ raise GitError("git executable not found on PATH") from e
+
+
+def verify_branch_exists(branch, repo="."):
+ try:
+ run_git(["rev-parse", "--verify", "--quiet", branch], repo)
+ except GitError:
+ raise GitError(f"Branch '{branch}' does not exist in this repository.")
+
+
+def get_merge_base(local, remote, repo="."):
+ """Commit where remote diverged from local."""
+ return run_git(["merge-base", local, remote], repo)
+
+
+def get_commit_date(commit_hash, repo="."):
+ """ISO8601 commit date of a commit, as a timezone-aware datetime."""
+ iso = run_git(["show", "-s", "--format=%cI", commit_hash], repo)
+ return datetime.fromisoformat(iso)
+
+
+def days_since(commit_hash, repo="."):
+ commit_dt = get_commit_date(commit_hash, repo)
+ now = datetime.now(timezone.utc)
+ delta = now - commit_dt.astimezone(timezone.utc)
+ return delta.days
+
+
+def get_commit_count(local, remote, repo="."):
+ """Number of commits on remote not yet on local (i.e. being merged in)."""
+ out = run_git(["rev-list", "--count", f"{local}..{remote}"], repo)
+ return int(out)
+
+
+def get_diff_shortstat(local, remote, repo="."):
+ """Lines added/removed between merge-base(local,remote) and remote."""
+ out = run_git(["diff", "--shortstat", f"{local}...{remote}"], repo)
+ added = removed = 0
+ if out:
+ add_match = re.search(r"(\d+) insertion", out)
+ rem_match = re.search(r"(\d+) deletion", out)
+ if add_match:
+ added = int(add_match.group(1))
+ if rem_match:
+ removed = int(rem_match.group(1))
+ return added, removed
+
+
+def get_file_changes(local, remote, repo="."):
+ """Count of files added (A) and removed (D) between local and remote."""
+ out = run_git(["diff", "--name-status", f"{local}...{remote}"], repo)
+ added_files = removed_files = 0
+ for line in out.splitlines():
+ if not line.strip():
+ continue
+ status = line.split("\t", 1)[0]
+ # status can be 'A', 'D', 'M', or rename/copy scores like 'R100'
+ if status.startswith("A"):
+ added_files += 1
+ elif status.startswith("D"):
+ removed_files += 1
+ return added_files, removed_files
+
+
+def read_message(args):
+ if args.message is not None:
+ return args.message
+ with open(args.message_file, "r", encoding="utf-8") as f:
+ return f.read().strip()
+ return "None"
+
+
+def build_summary(local, remote, msg, repo="."):
+ merge_base_hash = get_merge_base(local, remote, repo)
+ short_hash = run_git(["rev-parse", "--short", merge_base_hash], repo)
+ n_days = days_since(merge_base_hash, repo)
+ n_commits = get_commit_count(local, remote, repo)
+ add_chg, rem_chg = get_diff_shortstat(local, remote, repo)
+ add_file, rem_file = get_file_changes(local, remote, repo)
+
+ summary = TEMPLATE.format(
+ remote=remote,
+ local=local,
+ msg=msg,
+ n=n_commits,
+ commit_hash=short_hash,
+ days=n_days,
+ add_chg=add_chg,
+ rem_chg=rem_chg,
+ add_file=add_file,
+ rem_file=rem_file,
+ )
+ return summary
+
+
+def perform_merge(local, remote, commit_message, repo="."):
+ run_git(["checkout", local], repo)
+ run_git(["merge", "--no-ff", remote, "-m", commit_message], repo)
+
+
+def parse_args(argv=None):
+ parser = argparse.ArgumentParser(
+ description="Merge a remote branch into a local branch and report a summary."
+ )
+ parser.add_argument("local_branch", help="Branch to merge INTO")
+ parser.add_argument("remote_branch", help="Branch to merge (source)")
+
+ msg_group = parser.add_mutually_exclusive_group(required=True)
+ msg_group.add_argument(
+ "-m", "--message", help="Merge commit message, given directly as text"
+ )
+ msg_group.add_argument(
+ "-f",
+ "--message-file",
+ help="Path to a file containing the merge commit message",
+ )
+
+ parser.add_argument(
+ "--repo",
+ default=".",
+ help="Path to the git repository (default: current directory)",
+ )
+ parser.add_argument(
+ "--dry-run",
+ action="store_true",
+ help="Only print the summary report; do not perform the actual merge",
+ )
+
+ return parser.parse_args(argv)
+
+
+def main(argv=None):
+ args = parse_args(argv)
+ repo = args.repo
+
+ try:
+ verify_branch_exists(args.local_branch, repo)
+ verify_branch_exists(args.remote_branch, repo)
+
+ msg = read_message(args)
+ summary = build_summary(args.local_branch, args.remote_branch, msg, repo)
+
+ print(summary)
+
+ if args.dry_run:
+ print("[dry-run] No merge performed.")
+ return 0
+
+ perform_merge(args.local_branch, args.remote_branch, summary, repo)
+ print(f"Merge complete: '{args.remote_branch}' merged into '{args.local_branch}'.")
+ return 0
+
+ except GitError as e:
+ print(f"Error: {e}", file=sys.stderr)
+ return 1
+ except FileNotFoundError:
+ print(f"Error: message file '{args.message_file}' not found.", file=sys.stderr)
+ return 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())