1
0
mirror of https://github.com/openSUSE/osc.git synced 2026-03-09 18:45:53 +01:00
Files

342 lines
15 KiB
Python

import os
import shutil
import subprocess
import sys
import tempfile
import osc.commandline_git
class PullRequestForwardCommand(osc.commandline_git.GitObsCommand):
"""
Forward sources from one branch to another (Fork -> Sync -> Push -> PR)
"""
name = "forward"
parent = "PullRequestCommand"
def init_arguments(self):
self.add_argument_owner_repo()
self.add_argument(
"source_branch",
help="Source branch name (e.g. factory)",
)
self.add_argument(
"target_branch",
help="Target branch name (e.g. slfo-main)",
)
self.add_argument(
"--workdir",
help="Working directory for git operations (default: temporary directory)",
)
self.add_argument(
"--no-cleanup",
action="store_true",
help="Do not remove the temporary directory after completion",
)
self.add_argument(
"--dry-run",
action="store_true",
help="Do not actually create the pull request or push changes",
)
self.add_argument(
"--source-url",
help="URL of the source git repository (if different from upstream)",
)
self.add_argument(
"--title",
help="Pull request title",
)
self.add_argument(
"--description",
help="Pull request description (body)",
)
self.add_argument(
"-e", "--edit",
action="store_true",
help="Open an editor to edit the pull request title and description.",
)
self.add_argument(
"--mode",
required=True,
choices=["merge", "merge-unrelated", "fast-forward", "sync", "auto"],
help="merge: Merge the source branch into the target branch, remove any extra files not found in the target.\n"
"merge-unrelated: Same as merge, but with merging unrelated histories allowed. Consider using other modes first.\n"
"fast-forward: Fast-forward changes, don't create any merge commit.\n"
"sync: Create a new commit that synchronizes the contents of the branch with the source branch.\n"
"auto: Use fast-forward if possible, merge otherwise.",
)
def run(self, args):
from osc import gitea_api
self.print_gitea_settings()
upstream_owner, upstream_repo = args.owner_repo
source_branch = args.source_branch
target_branch = args.target_branch
# Fork if not exists
print(f"Checking fork for {upstream_owner}/{upstream_repo} ...", file=sys.stderr)
try:
fork_obj = gitea_api.Fork.create(self.gitea_conn, upstream_owner, upstream_repo)
fork_owner = fork_obj.owner
fork_repo = fork_obj.repo
print(f" * Fork created: {fork_owner}/{fork_repo}", file=sys.stderr)
except gitea_api.ForkExists as e:
fork_owner = e.fork_owner
fork_repo = e.fork_repo
print(f" * Fork already exists: {fork_owner}/{fork_repo}", file=sys.stderr)
except gitea_api.RepoExists as e:
fork_owner = e.owner
fork_repo = e.repo
print(f" * Repo already exists (assuming it is the fork): {fork_owner}/{fork_repo}", file=sys.stderr)
# Get clone URLs
upstream_repo_obj = gitea_api.Repo.get(self.gitea_conn, upstream_owner, upstream_repo)
_fork_repo_obj = gitea_api.Repo.get(self.gitea_conn, fork_owner, fork_repo)
upstream_url = upstream_repo_obj.ssh_url
# Setup Workdir
repo_dir = None
cleanup = False
if args.workdir:
# Smart detection of workdir
# Case A: args.workdir IS the repo (has .git)
if os.path.exists(os.path.join(args.workdir, ".git")):
repo_dir = args.workdir
# Case B: args.workdir contains the repo (args.workdir/repo/.git)
elif os.path.exists(os.path.join(args.workdir, fork_repo, ".git")):
repo_dir = os.path.join(args.workdir, fork_repo)
# Case C: args.workdir is a parent dir, repo doesn't exist yet -> create args.workdir/repo
else:
repo_dir = os.path.join(args.workdir, fork_repo)
os.makedirs(repo_dir, exist_ok=True)
else:
repo_dir = tempfile.mkdtemp(prefix="git-obs-forward-")
cleanup = not args.no_cleanup
print(f"Working in: {repo_dir}", file=sys.stderr)
try:
git = gitea_api.Git(repo_dir)
# Clone / Init
if not os.path.exists(os.path.join(repo_dir, ".git")):
print(f"Cloning {fork_owner}/{fork_repo} ...", file=sys.stderr)
gitea_api.Repo.clone(
self.gitea_conn,
fork_owner,
fork_repo,
# branch=args.branch,
directory=repo_dir,
use_http=self.gitea_login.git_uses_http,
add_remotes=False,
ssh_private_key_path=self.gitea_login.ssh_key,
# ssh_strict_host_key_checking=not(args.no_ssh_strict_host_key_checking),
)
else:
print(f"Using existing git repo in {repo_dir}", file=sys.stderr)
# Configure LFS to autodetect remotes
git._run_git(["config", "lfs.remote.autodetect", "true"])
# Add Upstream
current_remotes = git._run_git(["remote"]).splitlines()
if "upstream" not in current_remotes:
print(f"Adding upstream remote: {upstream_owner}/{upstream_repo} ({upstream_url})", file=sys.stderr)
git.add_remote("upstream", upstream_url)
else:
git._run_git(["remote", "set-url", "upstream", upstream_url])
# Fetch Upstream
print("Fetching upstream ...", file=sys.stderr)
git.fetch("upstream")
# Determine Source Ref
if args.source_url:
source_url = args.source_url
source_ref = f"source/{source_branch}"
source_remote = "source"
if "source" not in current_remotes:
print(f"Adding source remote: {source_url}", file=sys.stderr)
git.add_remote("source", source_url)
else:
git._run_git(["remote", "set-url", "source", source_url])
print("Fetching source ...", file=sys.stderr)
git.fetch("source")
else:
source_url = git.get_remote_url("upstream")
source_ref = f"upstream/{source_branch}"
source_remote = "upstream"
# Define a unique branch name for the forward operation
try:
source_commit_sha = git.get_branch_head(source_branch, remote=source_remote)
except subprocess.CalledProcessError as e:
raise gitea_api.GitObsRuntimeError(f"Could not get SHA for {source_ref}: {e}")
forward_branch = f"for/{target_branch}/forward-{source_commit_sha}"
print(f"Using forward branch on fork: {forward_branch}", file=sys.stderr)
if args.mode not in ["sync"]:
# Optimize LFS fetch: fetch only objects for new commits
# We identify commits in source_ref that are not in target_branch
# and fetch LFS objects for them from the appropriate remote.
print(f"Fetching LFS objects from {source_remote} for incoming commits ...", file=sys.stderr)
try:
# Get list of commits unique to source_branch that are not in target_branch
commits = git._run_git(["rev-list", f"{source_remote}/{source_branch}", f"^{source_remote}/{target_branch}"]).splitlines()
if commits:
print(f" * Found {len(commits)} commits to fetch LFS objects for.", file=sys.stderr)
# Loop through commits as requested
for commit in commits:
print(f" Fetching LFS for commit {commit} ...", file=sys.stderr, end="\r")
git._run_git(["lfs", "fetch", source_remote, commit])
print("", file=sys.stderr) # Newline after progress
else:
print(" * No new commits to fetch LFS objects for.", file=sys.stderr)
except subprocess.CalledProcessError as e:
# Fallback or ignore if LFS fails/missing
print(f"LFS fetch warning: {e}", file=sys.stderr)
# Checkout forward branch (tracking upstream/target_branch)
print(f"Creating/resetting forward branch '{forward_branch}' from 'upstream/{target_branch}'", file=sys.stderr)
try:
git._run_git(["checkout", "-B", forward_branch, f"upstream/{target_branch}"])
except subprocess.CalledProcessError:
raise gitea_api.GitObsRuntimeError(f"Failed to checkout upstream/{target_branch}. Does it exist?")
# Check for unrelated histories
if args.mode == "merge":
try:
# Returns non-zero if no common ancestor
git._run_git(["merge-base", source_ref, forward_branch])
except subprocess.CalledProcessError:
if args.mode not in ["merge-unrelated", "sync"]:
raise gitea_api.GitObsRuntimeError(f"Unrelated histories in '{source_ref}' and 'upstream/{target_branch}'. Use --mode=merge-unrelated or --mode=sync")
# Determine PR message and merge commit message
title = args.title or f"Sync with {source_branch} branch"
description = args.description or f"URL: {source_url}\nBranch: {source_branch}\nCommit: {source_commit_sha}"
if git.has_changes:
raise gitea_api.GitObsRuntimeError("There are uncommited changes in git. Aborting.")
if git.get_branch_head() == source_commit_sha:
raise gitea_api.GitObsRuntimeError("There are no changes. Aborting.")
# Merge Source Branch (Theirs)
is_fast_forwardable = git.branch_is_fast_forwardable(source_ref)
if args.mode in ["merge", "merge-unrelated", "fast-forward", "auto"]:
print(f"Merging {source_ref} into {forward_branch} using mode '{args.mode}' ...", file=sys.stderr)
try:
merge_cmd = ["merge", "--no-commit", "-X", "theirs"]
if args.mode == "auto":
# don't enforce fast-forwarding mode in any way
pass
elif args.mode == "fast-forward" or is_fast_forwardable:
merge_cmd += ["--ff-only"]
else:
merge_cmd += ["--no-ff"]
if args.mode == "merge-unrelated":
merge_cmd.append("--allow-unrelated-histories")
merge_cmd += [source_ref]
git._run_git(merge_cmd)
except subprocess.CalledProcessError as e:
raise gitea_api.GitObsRuntimeError(f"Merge failed: {e}")
# The git merge above only merges the sources while resolving conflicts,
# but it doesn't remove any files that do not exist in the ref we're merging from.
# Running git read-tree does the cleanup for us.
if not is_fast_forwardable or args.mode == "sync":
print("Syncing file tree with in {forward_branch} with {source_ref} ...", file=sys.stderr)
git._run_git(["read-tree", "-u", "--reset", source_ref])
commit_message = f"{title}\n\n{description}"
if args.edit and not args.dry_run:
from osc.gitea_api.common import edit_message
template = (
f"{title}\n\n"
f"{description}\n\n"
f"# Please enter the pull request title and description.\n"
f"# The first line is the title, the rest (after a blank line) is the description.\n"
f"# Lines starting with '#' will be ignored.\n"
)
message = edit_message(template)
# Filter out comments and strip whitespace
lines = [line for line in message.split("\n") if not line.strip().startswith("#")]
while lines and not lines[0].strip():
lines.pop(0) # remove leading blank lines
if not lines:
raise gitea_api.GitObsRuntimeError("Aborting due to empty message.")
title = lines.pop(0).strip()
if not title:
raise gitea_api.GitObsRuntimeError("Aborting due to empty title.")
# remove blank lines between title and body
while lines and not lines[0].strip():
lines.pop(0)
description = "\n".join(lines).strip()
if git.has_changes:
git.commit(msg=commit_message)
# Push to Fork
if args.dry_run:
print(f"[DRY RUN] Would push '{forward_branch}' to origin", file=sys.stderr)
else:
print(f"Pushing '{forward_branch}' to {fork_owner}/{fork_repo} ...", file=sys.stderr)
try:
# Always force-push to the temporary forward branch
git.push("origin", forward_branch, force=True)
except subprocess.CalledProcessError as e:
raise gitea_api.GitObsRuntimeError(f"Push failed: {e}")
# Create PR
if args.dry_run:
print(f"[DRY RUN] Would create PR for branch '{forward_branch}': {title}", file=sys.stderr)
return
print("Creating Pull Request ...", file=sys.stderr)
try:
pr_obj = gitea_api.PullRequest.create(
self.gitea_conn,
target_owner=upstream_owner,
target_repo=upstream_repo,
target_branch=target_branch,
source_owner=fork_owner,
source_branch=forward_branch,
title=title,
description=description,
)
print("", file=sys.stderr)
print("Pull request created:", file=sys.stderr)
print(pr_obj.to_human_readable_string())
except gitea_api.GiteaException as e:
# Handle case where PR already exists
if "pull request already exists" in str(e).lower():
print(f" * Pull request already exists.", file=sys.stderr)
else:
raise
finally:
if cleanup:
print(f"Cleaning up {repo_dir} ...", file=sys.stderr)
shutil.rmtree(repo_dir)