feat(#1399): hive-forge clone verb + pr-create --agit for no-fork PRs

This commit is contained in:
damocles 2026-06-05 19:10:09 +02:00 committed by mara
commit c103ae5f10
7 changed files with 347 additions and 11 deletions

View file

@ -10,6 +10,14 @@
//! through. Default behaviour is unchanged: no push unless asked.
//! Closes the auto-push half of #222 per operator decision (opt-in
//! flag).
//!
//! With `--agit` the PR is opened via Forgejo's `AGit` flow instead of
//! the REST API: the current `HEAD` is pushed to `refs/for/<base>/<topic>`
//! (run from inside a cloned repo), which opens a PR even when the user
//! is a read-only collaborator who can't push branches. Re-running with
//! the same `--topic` updates the open PR. This is the path agents use
//! to contribute to `internal/knowledge`; pair it with `hive-forge clone`
//! (closes #1399).
use std::process::{Command, Stdio};
@ -25,9 +33,10 @@ pub struct Args {
/// PR title.
#[arg(long)]
title: String,
/// Head branch.
#[arg(long)]
head: String,
/// Head branch. Not required (and ignored) in `--agit` mode, which
/// pushes the current `HEAD`.
#[arg(long, required_unless_present = "agit")]
head: Option<String>,
/// Base branch (default: main).
#[arg(long, default_value = "main")]
base: String,
@ -37,7 +46,8 @@ pub struct Args {
/// Read body from a file. `-` means stdin.
#[arg(long = "body-file")]
body_file: Option<String>,
/// Open as draft.
/// Open as draft. Ignored in `--agit` mode (the `AGit` push has no
/// draft push-option).
#[arg(long)]
draft: bool,
/// Push the local `--head` branch to `--remote` before creating
@ -45,10 +55,22 @@ pub struct Args {
/// hint block (we print the URL ourselves).
#[arg(long)]
push: bool,
/// Remote name to push to when `--push` is set. Defaults to the
/// hyperhive convention `forge`.
#[arg(long, default_value = "forge")]
remote: String,
/// Remote name to push to. Defaults to the hyperhive convention
/// `forge` for the normal flow, and `origin` in `--agit` mode (the
/// remote a `hive-forge clone` sets up).
#[arg(long)]
remote: Option<String>,
/// Open the PR via Forgejo's `AGit` flow: push the current `HEAD`
/// to `refs/for/<base>/<topic>` instead of calling the REST API.
/// Works for read-only collaborators (no branch-push needed). Run
/// from inside a cloned repo.
#[arg(long)]
agit: bool,
/// `AGit` topic — groups repeated pushes into ONE PR (re-running
/// with the same topic updates it). Defaults to the current branch
/// name, or `contribution`. Only meaningful with `--agit`.
#[arg(long)]
topic: Option<String>,
}
/// # Errors
@ -60,13 +82,23 @@ pub struct Args {
/// URL to stdout.
pub fn run(client: &Client, args: Args) -> Result<()> {
let body = body::resolve(args.body.as_deref(), args.body_file.as_deref())?.unwrap_or_default();
if args.agit {
return agit_create(&args, &body);
}
// Normal REST flow. `--head` is required (clap enforces it unless
// `--agit`), so unwrap is safe here.
let head = args
.head
.as_deref()
.expect("clap requires --head unless --agit");
let remote = args.remote.as_deref().unwrap_or("forge");
if args.push {
push_branch(&args.remote, &args.head)?;
push_branch(remote, head)?;
}
let repo = client.repo();
let payload = json!({
"title": args.title,
"head": args.head,
"head": head,
"base": args.base,
"body": body,
"draft": args.draft,
@ -79,6 +111,100 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
Ok(())
}
/// Open (or update) a PR via Forgejo's `AGit` flow: push the current
/// `HEAD` to `refs/for/<base>/<topic>` with the PR metadata carried as
/// push options. Run from inside the cloned repo's working tree.
/// `force-push=true` lets a re-run with the same topic UPDATE the open
/// PR (the new commit branches from base's tip, which the forge sees as
/// non-fast-forward against the existing PR head and rejects without it).
fn agit_create(args: &Args, body: &str) -> Result<()> {
let remote = args.remote.as_deref().unwrap_or("origin");
let topic = match &args.topic {
Some(t) => t.clone(),
None => current_branch().unwrap_or_else(|| "contribution".to_owned()),
};
let refspec = format!("HEAD:refs/for/{}/{topic}", args.base);
let mut push_args = vec![
"push".to_owned(),
remote.to_owned(),
refspec,
"-o".to_owned(),
format!("topic={topic}"),
"-o".to_owned(),
format!("title={}", args.title),
"-o".to_owned(),
"force-push=true".to_owned(),
];
if !body.is_empty() {
push_args.push("-o".to_owned());
push_args.push(format!("description={body}"));
}
let arg_refs: Vec<&str> = push_args.iter().map(String::as_str).collect();
let output = Command::new("git")
.args(&arg_refs)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.with_context(|| format!("failed to spawn `git push {remote}` (AGit)"))?;
let stderr = String::from_utf8_lossy(&output.stderr);
if !output.status.success() {
anyhow::bail!("`git push {remote}` (AGit) failed:\n{stderr}");
}
if let Some(url) = parse_pr_url(&stderr) {
println!("{url}");
} else {
// Push landed but no PR URL in the sideband — surface the raw
// remote output so the caller can find the link themselves.
eprint!("{stderr}");
println!(
"AGit push to refs/for/{}/{topic} succeeded (PR URL not parsed from output above)",
args.base
);
}
Ok(())
}
/// Current git branch name (`git rev-parse --abbrev-ref HEAD`), or
/// `None` on detached HEAD / error.
fn current_branch() -> Option<String> {
let out = Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let name = String::from_utf8_lossy(&out.stdout).trim().to_owned();
if name.is_empty() || name == "HEAD" {
None
} else {
Some(name)
}
}
/// Scan `git push` stderr for the PR URL. The forge prints it on a
/// `remote:` sideband line following the "Create a new pull request" /
/// "Visit the existing pull request" opener; rather than match the
/// exact phrasing, we pick the first `remote:` URL containing `/pulls/`.
fn parse_pr_url(stderr: &str) -> Option<String> {
for line in stderr.lines() {
let Some(content) = line.trim_start().strip_prefix("remote:") else {
continue;
};
let content = content.trim_start();
if let Some(start) = content.find("http") {
let url = content[start..]
.split_whitespace()
.next()
.unwrap_or_default();
if url.contains("/pulls/") {
return Some(url.to_owned());
}
}
}
None
}
/// Run `git push <remote> <head>` and pass stderr through with the
/// post-push PR-hint block filtered out. On failure, bubble up
/// git's full stderr so the user sees why the push didn't land.
@ -153,6 +279,36 @@ fn is_pr_hint_opener(line: &str) -> bool {
mod tests {
use super::*;
#[test]
fn agit_parse_pr_url_picks_pulls_link() {
let stderr = "\
remote: Visit the existing pull request:\n\
remote: http://forge.example/internal/knowledge/pulls/2\n\
remote: \n\
To http://localhost:3000/internal/knowledge.git\n\
* [new reference] HEAD -> refs/pull/2/head\n";
assert_eq!(
parse_pr_url(stderr).as_deref(),
Some("http://forge.example/internal/knowledge/pulls/2")
);
}
#[test]
fn agit_parse_pr_url_handles_created_phrasing() {
let stderr = "\
remote: Create a new pull request:\n\
remote: http://forge.example/internal/knowledge/pulls/7\n";
assert_eq!(
parse_pr_url(stderr).as_deref(),
Some("http://forge.example/internal/knowledge/pulls/7")
);
}
#[test]
fn agit_parse_pr_url_none_when_absent() {
assert_eq!(parse_pr_url("To http://localhost:3000/x/y.git\n"), None);
}
#[test]
fn strips_create_pr_hint_block() {
let stderr = "\