feat(#1399): hive-forge clone verb + pr-create --agit for no-fork PRs
This commit is contained in:
parent
e7a68472fd
commit
c103ae5f10
7 changed files with 347 additions and 11 deletions
|
|
@ -292,7 +292,7 @@ hive-forge/ Forgejo CLI wrapper (`hive-forge` binary)
|
||||||
close, labels, list, milestone, branches,
|
close, labels, list, milestone, branches,
|
||||||
tree-sha, diff, subscription, attach-issue,
|
tree-sha, diff, subscription, attach-issue,
|
||||||
attach-comment, attachment-get, lint,
|
attach-comment, attachment-get, lint,
|
||||||
pr-status). Replaces the
|
pr-status, clone). Replaces the
|
||||||
600-line hive-forge-tools.nix bash script.
|
600-line hive-forge-tools.nix bash script.
|
||||||
|
|
||||||
hive-matrix-mcp/ per-agent matrix-sdk integration.
|
hive-matrix-mcp/ per-agent matrix-sdk integration.
|
||||||
|
|
|
||||||
|
|
@ -53,8 +53,38 @@ hive-forge attach-comment 18042 /path/to/file # upload a file attachment to a c
|
||||||
hive-forge attachment-get <uuid> # download an attachment; prints resolved path to stdout
|
hive-forge attachment-get <uuid> # download an attachment; prints resolved path to stdout
|
||||||
hive-forge subscription --watch # subscribe to repo notifications
|
hive-forge subscription --watch # subscribe to repo notifications
|
||||||
hive-forge subscription --unwatch # unsubscribe
|
hive-forge subscription --unwatch # unsubscribe
|
||||||
|
hive-forge -r internal/knowledge clone # clone with creds auto-injected
|
||||||
|
hive-forge -r internal/knowledge pr-create --agit --topic foo --title "..." # open PR via AGit (no fork)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Contributing to a read-only repo (`clone` + `pr-create --agit`)
|
||||||
|
|
||||||
|
Agents are read-only collaborators on some repos (e.g.
|
||||||
|
`internal/knowledge`) and so can't push branches. Forgejo's AGit flow
|
||||||
|
lets a read-only user open a PR by pushing the current `HEAD` to the
|
||||||
|
magic ref `refs/for/<base>/<topic>`. Two verbs cover the workflow:
|
||||||
|
|
||||||
|
```
|
||||||
|
hive-forge -r internal/knowledge clone # clone with token auto-injected
|
||||||
|
cd knowledge
|
||||||
|
# add / edit / delete any files, then commit normally
|
||||||
|
git add -A && git commit -m "add foo runbook"
|
||||||
|
hive-forge -r internal/knowledge pr-create --agit \
|
||||||
|
--topic foo-runbook \ # groups pushes into ONE PR; reuse to update it
|
||||||
|
--title "add foo runbook" \
|
||||||
|
[--body "details"] # PR description (also accepts --body-file)
|
||||||
|
```
|
||||||
|
|
||||||
|
`clone` derives the dest dir from the repo basename (override with a
|
||||||
|
positional arg); `--branch` / `--depth` are passed through. The token
|
||||||
|
is injected into the clone's `origin` remote so `pr-create --agit`
|
||||||
|
(default remote `origin`) can push without re-auth.
|
||||||
|
|
||||||
|
`pr-create --agit` prints the PR URL. Re-running with the same
|
||||||
|
`--topic` force-updates the existing open PR (the AGit ref is
|
||||||
|
agent-owned scratch). Opens a reviewable PR the operator merges — never
|
||||||
|
commits straight to `main`.
|
||||||
|
|
||||||
`hive-forge <verb> --help` prints the full signature for any verb.
|
`hive-forge <verb> --help` prints the full signature for any verb.
|
||||||
|
|
||||||
### `pr-status`
|
### `pr-status`
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,10 @@ const DEFAULT_REPO: &str = "hyperhive/hyperhive";
|
||||||
pub struct Client {
|
pub struct Client {
|
||||||
http: HttpClient,
|
http: HttpClient,
|
||||||
base: String,
|
base: String,
|
||||||
|
/// Per-agent forge token. Kept alongside the pre-built auth header
|
||||||
|
/// so verbs that shell out to `git` (e.g. `knowledge`) can assemble
|
||||||
|
/// an authenticated push URL without re-reading the token file.
|
||||||
|
token: String,
|
||||||
/// Default repo used when a verb doesn't carry an explicit
|
/// Default repo used when a verb doesn't carry an explicit
|
||||||
/// `[repo]` override.
|
/// `[repo]` override.
|
||||||
pub default_repo: String,
|
pub default_repo: String,
|
||||||
|
|
@ -59,11 +63,29 @@ impl Client {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
http,
|
http,
|
||||||
base,
|
base,
|
||||||
|
token,
|
||||||
default_repo,
|
default_repo,
|
||||||
json_mode,
|
json_mode,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Assemble an authenticated git URL for `repo` (e.g.
|
||||||
|
/// `internal/knowledge`) by injecting the agent's forge user +
|
||||||
|
/// token into the base URL's authority: `http://<user>:<token>@host/<repo>.git`.
|
||||||
|
/// The user comes from `HIVE_LABEL` (the agent's forge login),
|
||||||
|
/// falling back to `oauth2` which Forgejo also accepts as the
|
||||||
|
/// token-bearer username. Used by `knowledge` to clone/push.
|
||||||
|
#[must_use]
|
||||||
|
pub fn authed_git_url(&self, repo: &str) -> String {
|
||||||
|
let user = std::env::var("HIVE_LABEL").unwrap_or_else(|_| "oauth2".to_owned());
|
||||||
|
// Split scheme from authority so credentials land in the right spot.
|
||||||
|
let (scheme, host) = self
|
||||||
|
.base
|
||||||
|
.split_once("://")
|
||||||
|
.unwrap_or(("http", self.base.as_str()));
|
||||||
|
format!("{scheme}://{user}:{}@{host}/{repo}.git", self.token)
|
||||||
|
}
|
||||||
|
|
||||||
/// True when the operator passed the global `--json` flag.
|
/// True when the operator passed the global `--json` flag.
|
||||||
/// Verbs that have a human-readable default branch on this to
|
/// Verbs that have a human-readable default branch on this to
|
||||||
/// emit JSON instead. Verbs whose only output format is JSON
|
/// emit JSON instead. Verbs whose only output format is JSON
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,9 @@ enum Verb {
|
||||||
/// review verdicts, last-comment time (`--pr <n>`). `--sha` is a
|
/// review verdicts, last-comment time (`--pr <n>`). `--sha` is a
|
||||||
/// CI-only fast path. Exit code is a merge-readiness verdict.
|
/// CI-only fast path. Exit code is a merge-readiness verdict.
|
||||||
PrStatus(verbs::pr_status::Args),
|
PrStatus(verbs::pr_status::Args),
|
||||||
|
/// Clone a forge repo (default `-r`/`HIVE_FORGE_REPO`) with
|
||||||
|
/// credentials auto-injected. Pairs with `pr-create --agit`.
|
||||||
|
Clone(verbs::clone::Args),
|
||||||
/// Triage lint queries (unassigned / no-reviewer / stale-branches / assignments).
|
/// Triage lint queries (unassigned / no-reviewer / stale-branches / assignments).
|
||||||
Lint(verbs::lint::Args),
|
Lint(verbs::lint::Args),
|
||||||
/// List issues / PRs with filters (`--kind`, `--state`, `--assignee`,
|
/// List issues / PRs with filters (`--kind`, `--state`, `--assignee`,
|
||||||
|
|
@ -126,6 +129,7 @@ fn main() -> Result<()> {
|
||||||
Verb::Close(a) => verbs::close::run(&client, a),
|
Verb::Close(a) => verbs::close::run(&client, a),
|
||||||
Verb::Labels(a) => verbs::labels::run(&client, a),
|
Verb::Labels(a) => verbs::labels::run(&client, a),
|
||||||
Verb::PrStatus(a) => verbs::pr_status::run(&client, a),
|
Verb::PrStatus(a) => verbs::pr_status::run(&client, a),
|
||||||
|
Verb::Clone(a) => verbs::clone::run(&client, a),
|
||||||
Verb::Lint(a) => verbs::lint::run(&client, a),
|
Verb::Lint(a) => verbs::lint::run(&client, a),
|
||||||
Verb::List(a) => verbs::list::run(&client, a),
|
Verb::List(a) => verbs::list::run(&client, a),
|
||||||
Verb::Milestone(a) => verbs::milestone::run(&client, a),
|
Verb::Milestone(a) => verbs::milestone::run(&client, a),
|
||||||
|
|
|
||||||
123
hive-forge/src/verbs/clone.rs
Normal file
123
hive-forge/src/verbs/clone.rs
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
//! `clone [<dest>] [--branch <b>] [--depth <n>]` — clone a forge repo
|
||||||
|
//! with credentials auto-injected, so agents don't hand-assemble
|
||||||
|
//! token-bearing URLs. The repo is the standard `-r/--repo` (default
|
||||||
|
//! `HIVE_FORGE_REPO`). Pairs with `pr-create --agit`: clone, edit +
|
||||||
|
//! commit normally, then open a PR via the `AGit` ref (closes #1399).
|
||||||
|
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use clap::Args as ClapArgs;
|
||||||
|
|
||||||
|
use crate::client::Client;
|
||||||
|
|
||||||
|
#[derive(ClapArgs)]
|
||||||
|
pub struct Args {
|
||||||
|
/// Destination directory. Defaults to the repo's basename
|
||||||
|
/// (e.g. `internal/knowledge` → `knowledge`).
|
||||||
|
dest: Option<String>,
|
||||||
|
/// Branch to check out after cloning.
|
||||||
|
#[arg(long)]
|
||||||
|
branch: Option<String>,
|
||||||
|
/// Shallow-clone depth (omit for a full clone).
|
||||||
|
#[arg(long)]
|
||||||
|
depth: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the `git clone` shellout fails (bad repo, auth
|
||||||
|
/// rejected, network) or the destination can't be derived.
|
||||||
|
pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
|
let repo = client.repo();
|
||||||
|
let dest = match args.dest {
|
||||||
|
Some(d) => d,
|
||||||
|
None => repo
|
||||||
|
.rsplit('/')
|
||||||
|
.next()
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(str::to_owned)
|
||||||
|
.with_context(|| format!("clone: cannot derive a destination dir from repo {repo}"))?,
|
||||||
|
};
|
||||||
|
let url = client.authed_git_url(repo);
|
||||||
|
|
||||||
|
let mut git_args = vec!["clone".to_owned()];
|
||||||
|
if let Some(depth) = args.depth {
|
||||||
|
git_args.push(format!("--depth={depth}"));
|
||||||
|
}
|
||||||
|
if let Some(branch) = &args.branch {
|
||||||
|
git_args.push("--branch".to_owned());
|
||||||
|
git_args.push(branch.clone());
|
||||||
|
}
|
||||||
|
git_args.push(url);
|
||||||
|
git_args.push(dest.clone());
|
||||||
|
|
||||||
|
let arg_refs: Vec<&str> = git_args.iter().map(String::as_str).collect();
|
||||||
|
let out = Command::new("git")
|
||||||
|
.args(&arg_refs)
|
||||||
|
.output()
|
||||||
|
.context("spawn `git clone`")?;
|
||||||
|
if !out.status.success() {
|
||||||
|
// Scrub the token from any URL echoed back in git's error.
|
||||||
|
let stderr = scrub_credentials(&String::from_utf8_lossy(&out.stderr));
|
||||||
|
bail!("clone: git clone {repo} failed:\n{stderr}");
|
||||||
|
}
|
||||||
|
// Print the destination so callers can `cd` into it.
|
||||||
|
println!("{dest}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Redact the `user:token@` userinfo from any URL in `s` so a token
|
||||||
|
/// never lands in an error message / log. Replaces the credential span
|
||||||
|
/// with `***` while keeping the rest of the URL legible.
|
||||||
|
fn scrub_credentials(s: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(s.len());
|
||||||
|
let mut rest = s;
|
||||||
|
while let Some(scheme_at) = rest.find("://") {
|
||||||
|
let after_scheme = scheme_at + 3;
|
||||||
|
// Userinfo runs from after `://` up to the next `@`, but only if
|
||||||
|
// that `@` comes before the next `/` (i.e. it's in the authority).
|
||||||
|
let authority = &rest[after_scheme..];
|
||||||
|
let at = authority.find('@');
|
||||||
|
let slash = authority.find('/');
|
||||||
|
match (at, slash) {
|
||||||
|
(Some(a), maybe_slash) if maybe_slash.is_none_or(|sl| a < sl) => {
|
||||||
|
out.push_str(&rest[..after_scheme]);
|
||||||
|
out.push_str("***");
|
||||||
|
out.push('@');
|
||||||
|
rest = &authority[a + 1..];
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// No credentials in this URL; emit up to here and move on.
|
||||||
|
out.push_str(&rest[..after_scheme]);
|
||||||
|
rest = authority;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push_str(rest);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scrubs_token_userinfo() {
|
||||||
|
let s = "fatal: unable to access 'http://damocles:deadbeef@localhost:3000/internal/knowledge.git/'";
|
||||||
|
let scrubbed = scrub_credentials(s);
|
||||||
|
assert!(!scrubbed.contains("deadbeef"));
|
||||||
|
assert!(scrubbed.contains("http://***@localhost:3000/internal/knowledge.git"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn leaves_credential_free_urls_intact() {
|
||||||
|
let s = "Cloning into 'http://localhost:3000/x/y.git'...";
|
||||||
|
assert_eq!(scrub_credentials(s), s);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn handles_no_url() {
|
||||||
|
assert_eq!(scrub_credentials("plain message"), "plain message");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ pub mod assign;
|
||||||
pub mod attach;
|
pub mod attach;
|
||||||
pub mod attachment_get;
|
pub mod attachment_get;
|
||||||
pub mod branches;
|
pub mod branches;
|
||||||
|
pub mod clone;
|
||||||
pub mod close;
|
pub mod close;
|
||||||
pub mod comment;
|
pub mod comment;
|
||||||
pub mod comment_edit;
|
pub mod comment_edit;
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,14 @@
|
||||||
//! through. Default behaviour is unchanged: no push unless asked.
|
//! through. Default behaviour is unchanged: no push unless asked.
|
||||||
//! Closes the auto-push half of #222 per operator decision (opt-in
|
//! Closes the auto-push half of #222 per operator decision (opt-in
|
||||||
//! flag).
|
//! 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};
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
|
|
@ -25,9 +33,10 @@ pub struct Args {
|
||||||
/// PR title.
|
/// PR title.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
title: String,
|
title: String,
|
||||||
/// Head branch.
|
/// Head branch. Not required (and ignored) in `--agit` mode, which
|
||||||
#[arg(long)]
|
/// pushes the current `HEAD`.
|
||||||
head: String,
|
#[arg(long, required_unless_present = "agit")]
|
||||||
|
head: Option<String>,
|
||||||
/// Base branch (default: main).
|
/// Base branch (default: main).
|
||||||
#[arg(long, default_value = "main")]
|
#[arg(long, default_value = "main")]
|
||||||
base: String,
|
base: String,
|
||||||
|
|
@ -37,7 +46,8 @@ pub struct Args {
|
||||||
/// Read body from a file. `-` means stdin.
|
/// Read body from a file. `-` means stdin.
|
||||||
#[arg(long = "body-file")]
|
#[arg(long = "body-file")]
|
||||||
body_file: Option<String>,
|
body_file: Option<String>,
|
||||||
/// Open as draft.
|
/// Open as draft. Ignored in `--agit` mode (the `AGit` push has no
|
||||||
|
/// draft push-option).
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
draft: bool,
|
draft: bool,
|
||||||
/// Push the local `--head` branch to `--remote` before creating
|
/// Push the local `--head` branch to `--remote` before creating
|
||||||
|
|
@ -45,10 +55,22 @@ pub struct Args {
|
||||||
/// hint block (we print the URL ourselves).
|
/// hint block (we print the URL ourselves).
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
push: bool,
|
push: bool,
|
||||||
/// Remote name to push to when `--push` is set. Defaults to the
|
/// Remote name to push to. Defaults to the hyperhive convention
|
||||||
/// hyperhive convention `forge`.
|
/// `forge` for the normal flow, and `origin` in `--agit` mode (the
|
||||||
#[arg(long, default_value = "forge")]
|
/// remote a `hive-forge clone` sets up).
|
||||||
remote: String,
|
#[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
|
/// # Errors
|
||||||
|
|
@ -60,13 +82,23 @@ pub struct Args {
|
||||||
/// URL to stdout.
|
/// URL to stdout.
|
||||||
pub fn run(client: &Client, args: Args) -> Result<()> {
|
pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
let body = body::resolve(args.body.as_deref(), args.body_file.as_deref())?.unwrap_or_default();
|
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 {
|
if args.push {
|
||||||
push_branch(&args.remote, &args.head)?;
|
push_branch(remote, head)?;
|
||||||
}
|
}
|
||||||
let repo = client.repo();
|
let repo = client.repo();
|
||||||
let payload = json!({
|
let payload = json!({
|
||||||
"title": args.title,
|
"title": args.title,
|
||||||
"head": args.head,
|
"head": head,
|
||||||
"base": args.base,
|
"base": args.base,
|
||||||
"body": body,
|
"body": body,
|
||||||
"draft": args.draft,
|
"draft": args.draft,
|
||||||
|
|
@ -79,6 +111,100 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
Ok(())
|
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
|
/// Run `git push <remote> <head>` and pass stderr through with the
|
||||||
/// post-push PR-hint block filtered out. On failure, bubble up
|
/// 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.
|
/// 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 {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn strips_create_pr_hint_block() {
|
fn strips_create_pr_hint_block() {
|
||||||
let stderr = "\
|
let stderr = "\
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue