1
0
mirror of https://github.com/openSUSE/osc.git synced 2026-02-22 03:35:29 +01:00

- Safer branching: the command now creates a dedicated, temporary branch on the user's fork (e.g., PR_factory_abc1234) for the forward-merge. This prevents conflicts with and accidental overwrites of existing development branches.

- Commit message: the pull request title and description are now used for the merge commit message. This creates a more informative and consistent git history, linking the merge commit directly to the resulting PR.
- Interactive editing: a new -e, --edit flag allows the user to open their default editor to interactively refine the PR title and description before submission.
- Unrelated history check: the command now checks for unrelated histories before merging and provides a clearer error message. A new --allow-unrelated-histories flag allows the user to override this check.
This commit is contained in:
Antonello Tartamo
2026-02-17 21:57:57 +01:00
parent 696583e302
commit 5fd05e48da

View File

@@ -38,12 +38,6 @@ class PullRequestForwardCommand(osc.commandline_git.GitObsCommand):
action="store_true",
help="Do not actually create the pull request or push changes",
)
self.add_argument(
"-f",
"--force",
action="store_true",
help="Force push to the fork (overwrite remote branch history)",
)
self.add_argument(
"--source-url",
help="URL of the source git repository (if different from upstream)",
@@ -56,6 +50,16 @@ class PullRequestForwardCommand(osc.commandline_git.GitObsCommand):
"--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(
"--allow-unrelated-histories",
action="store_true",
help="Allow merging unrelated histories",
)
def run(self, args):
from osc import gitea_api
@@ -88,7 +92,7 @@ class PullRequestForwardCommand(osc.commandline_git.GitObsCommand):
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
upstream_url = upstream_repo_obj.ssh_url
fork_url = fork_repo_obj.ssh_url # Prefer SSH for push if possible
# Setup Workdir
@@ -109,7 +113,14 @@ class PullRequestForwardCommand(osc.commandline_git.GitObsCommand):
if not os.path.exists(repo_dir):
os.makedirs(repo_dir)
else:
repo_dir = tempfile.mkdtemp(prefix="git-obs-forward-")
try:
repo_dir = tempfile.mkdtemp(prefix="git-obs-forward-")
except Exception as e:
print(f"Error creating temporary directory: {
e}", file=sys.stderr)
import traceback
traceback.print_exc()
sys.exit(1)
cleanup = not args.no_cleanup
print(f"Working in: {repo_dir}", file=sys.stderr)
@@ -123,7 +134,7 @@ class PullRequestForwardCommand(osc.commandline_git.GitObsCommand):
try:
git.clone(fork_url, directory=".")
except Exception as e:
raise e
raise e
else:
print(f"Using existing git repo in {repo_dir}", file=sys.stderr)
@@ -149,7 +160,7 @@ class PullRequestForwardCommand(osc.commandline_git.GitObsCommand):
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")
source_ref = f"source/{source_branch}"
@@ -163,48 +174,134 @@ class PullRequestForwardCommand(osc.commandline_git.GitObsCommand):
pass
source_ref = f"upstream/{source_branch}"
# Define a unique branch name for the forward operation
try:
source_commit_sha = git._run_git(
["rev-parse", "--short=7", source_ref]).strip()
except Exception as e:
print(f"{tty.colorize('ERROR', 'red,bold')}: Could not get SHA for {
source_ref}: {e}", file=sys.stderr)
sys.exit(1)
forward_branch = f"PR_{source_branch}_{source_commit_sha}"
print(f"Using forward branch on fork: {
forward_branch}", file=sys.stderr)
# 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.
lfs_remote = "source" if source_url else "upstream"
print(f"Fetching LFS objects from {lfs_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"{lfs_remote}/{source_branch}", f"^{lfs_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", lfs_remote, commit])
print("", file=sys.stderr) # Newline after progress
print("", file=sys.stderr) # Newline after progress
else:
print(" * No new commits to fetch LFS objects for.", file=sys.stderr)
except Exception as e:
# Fallback or ignore if LFS fails/missing
print(f"LFS fetch warning: {e}", file=sys.stderr)
# Checkout Target Branch (tracking upstream)
print(f"Checking out target branch: {target_branch} (tracking upstream/{target_branch})", 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", target_branch, f"upstream/{target_branch}"])
git._run_git(["checkout", "-B", forward_branch, f"upstream/{target_branch}"])
except Exception:
print(f"{tty.colorize('ERROR', 'red,bold')}: Failed to checkout upstream/{target_branch}. Does it exist?", file=sys.stderr)
sys.exit(1)
# Merge Source Branch (Theirs)
print(f"Merging {source_ref} with strategy 'theirs' ...", file=sys.stderr)
# Check for unrelated histories
try:
git._run_git(["merge", "-X", "theirs", "--allow-unrelated-histories", "--no-edit", source_ref])
# Returns non-zero if no common ancestor
git._run_git(["merge-base", source_ref, forward_branch])
except Exception:
if not args.allow_unrelated_histories:
print(
f"{tty.colorize('ERROR', 'red,bold')}: "
f"Unrelated histories in '{
source_ref}' and 'upstream/{target_branch}'. "
"Use --allow-unrelated-histories to merge.",
file=sys.stderr
)
sys.exit(1)
# Determine PR message and merge commit message
title = args.title or f"Forward {source_branch} to {target_branch}"
description = args.description or f"Automated forward of {
source_branch} to {target_branch} using git-obs."
if args.edit and not args.dry_run:
from osc.gitea_api.common import run_editor
message = (
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"
)
tmp_path = ''
try:
fd, tmp_path = tempfile.mkstemp(
prefix='osc-pr-forward-', text=True)
with os.fdopen(fd, 'w') as tmp:
tmp.write(message)
run_editor(tmp_path)
with open(tmp_path, 'r') as tmp:
edited_message = tmp.read()
finally:
if tmp_path and os.path.exists(tmp_path):
os.unlink(tmp_path)
# Filter out comments and strip whitespace
lines = [line for line in edited_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:
print(f"{tty.colorize('ERROR', 'red,bold')
}: Aborting due to empty message.", file=sys.stderr)
sys.exit(1)
title = lines.pop(0).strip()
if not title:
print(f"{tty.colorize('ERROR', 'red,bold')
}: Aborting due to empty title.", file=sys.stderr)
sys.exit(1)
# remove blank lines between title and body
while lines and not lines[0].strip():
lines.pop(0)
description = "\n".join(lines).strip()
# Merge Source Branch (Theirs)
commit_message = f"{title}\n\n{description}"
print(f"Merging {source_ref} into {forward_branch} with strategy 'theirs' ...", file=sys.stderr)
try:
merge_cmd = ["merge", "-X", "theirs"]
if args.allow_unrelated_histories:
merge_cmd.append("--allow-unrelated-histories")
merge_cmd.extend(["-m", commit_message, source_ref])
git._run_git(merge_cmd)
except Exception as e:
print(f"{tty.colorize('ERROR', 'red,bold')}: Merge failed: {e}", file=sys.stderr)
sys.exit(1)
# Clean files not in Source
print("Cleaning files not present in source ...", file=sys.stderr)
# Get list of files in source (recurse)
source_files_output = git._run_git(["ls-tree", "-r", "--name-only", source_ref])
source_files = set(source_files_output.splitlines())
@@ -214,40 +311,34 @@ class PullRequestForwardCommand(osc.commandline_git.GitObsCommand):
head_files = set(head_files_output.splitlines())
files_to_remove = list(head_files - source_files)
if files_to_remove:
print(f"Removing {len(files_to_remove)} files not present in {source_branch} ...", file=sys.stderr)
# Batching git rm to avoid command line length limits
chunk_size = 100
for i in range(0, len(files_to_remove), chunk_size):
chunk = files_to_remove[i : i + chunk_size]
chunk = files_to_remove[i: i + chunk_size]
git._run_git(["rm", "-f", "--"] + chunk)
git.commit(f"Clean files not present in {source_branch}")
else:
print("No extra files to clean.", file=sys.stderr)
# Push to Fork
if args.dry_run:
print("[DRY RUN] Would push to origin", file=sys.stderr)
print(f"[DRY RUN] Would push '{forward_branch}' to origin", file=sys.stderr)
else:
print(f"Pushing to {fork_owner}/{fork_repo} ...", file=sys.stderr)
print(f"Pushing '{forward_branch}' to {fork_owner}/{fork_repo} ...", file=sys.stderr)
try:
git.push("origin", target_branch, force=args.force)
# Always force-push to the temporary forward branch
git.push("origin", forward_branch, force=True)
except Exception as e:
print(f"{tty.colorize('ERROR', 'red,bold')}: Push failed: {e}", file=sys.stderr)
if not args.force:
print(f"{tty.colorize('HINT', 'yellow,bold')}: The local branch has diverged from the remote branch.", file=sys.stderr)
print(f"{tty.colorize('HINT', 'yellow,bold')}: This is expected when forwarding/resetting a branch.", file=sys.stderr)
print(f"{tty.colorize('HINT', 'yellow,bold')}: Run with --force to overwrite your fork's branch.", file=sys.stderr)
sys.exit(1)
# Create PR
title = args.title or f"Forward {source_branch} to {target_branch}"
description = args.description or f"Automated forward of {source_branch} to {target_branch} using git-obs."
if args.dry_run:
print(f"[DRY RUN] Would create PR: {title}", file=sys.stderr)
print(f"[DRY RUN] Would create PR for branch '{forward_branch}': {title}", file=sys.stderr)
return
print("Creating Pull Request ...", file=sys.stderr)
@@ -258,7 +349,7 @@ class PullRequestForwardCommand(osc.commandline_git.GitObsCommand):
target_repo=upstream_repo,
target_branch=target_branch,
source_owner=fork_owner,
source_branch=target_branch,
source_branch=forward_branch,
title=title,
description=description,
)
@@ -268,7 +359,7 @@ class PullRequestForwardCommand(osc.commandline_git.GitObsCommand):
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)
print(f" * Pull request already exists.", file=sys.stderr)
else:
raise