use std::io::Write as _;
use std::path::Path;
use std::process::Command;
use std::time::Instant;
use anyhow::{Context, Result, bail};
use git2::{BranchType, Repository, Sort};
use crate::core::msg;
use crate::core::repo;
use crate::git;
use crate::trace as loom_trace;
#[derive(Debug, PartialEq, Eq)]
enum RemoteType {
Plain,
GitHub,
AzureDevOps,
Gerrit { target_branch: String },
}
pub fn run(branch: Option<String>, no_pr: bool) -> Result<()> {
let repo = repo::open_repo()?;
let workdir = repo::require_workdir(&repo, "push")?.to_path_buf();
let info = repo::gather_repo_info(&repo, false, 1)?;
if info.branches.is_empty() {
bail!("No woven branches to push\nCreate a branch with `git loom branch` first");
}
let branch_name = match branch {
Some(b) => resolve_branch(&repo, &info, &b)?,
None => pick_branch(&info)?,
};
let remote_type = detect_remote_type(&repo, &workdir, &info.upstream.label)?;
let remote_name = resolve_push_remote(&repo, &workdir, &info.upstream.label, &remote_type);
let target_branch = extract_target_branch(&info.upstream.label);
if no_pr {
return match remote_type {
RemoteType::Gerrit { .. } => push_gerrit_no_pr(&workdir, &remote_name, &branch_name),
_ => push_plain(&workdir, &remote_name, &branch_name),
};
}
let base_oid = info.upstream.merge_base_oid;
match remote_type {
RemoteType::Plain => push_plain(&workdir, &remote_name, &branch_name),
RemoteType::GitHub => push_github(
&repo,
&workdir,
&remote_name,
&branch_name,
&target_branch,
base_oid,
&info.upstream.label,
),
RemoteType::AzureDevOps => push_azure(
&repo,
&workdir,
&remote_name,
&branch_name,
&target_branch,
base_oid,
),
RemoteType::Gerrit { target_branch } => {
push_gerrit(&workdir, &remote_name, &branch_name, &target_branch)
}
}
}
fn resolve_branch(repo: &Repository, info: &repo::RepoInfo, branch_arg: &str) -> Result<String> {
let name = repo::resolve_arg(repo, branch_arg, &[repo::TargetKind::Branch])?.expect_branch()?;
if info.branches.iter().any(|b| b.name == name) {
Ok(name)
} else {
bail!("Branch '{}' is not woven into the integration branch", name)
}
}
fn pick_branch(info: &repo::RepoInfo) -> Result<String> {
let items: Vec<String> = info.branches.iter().map(|b| b.name.clone()).collect();
msg::select("Select branch to push", items)
}
fn detect_remote_type(
repo: &Repository,
workdir: &Path,
upstream_label: &str,
) -> Result<RemoteType> {
if let Ok(config_value) = git::run_git_stdout(workdir, &["config", "--get", "loom.remote-type"])
{
let value = config_value.trim().to_lowercase();
if value == "github" {
return Ok(RemoteType::GitHub);
}
if value == "azure" {
return Ok(RemoteType::AzureDevOps);
}
if value == "gerrit" {
let target_branch = extract_target_branch(upstream_label);
return Ok(RemoteType::Gerrit { target_branch });
}
msg::warn(&format!(
"Unknown loom.remote-type '{}' — falling back to auto-detection.\n\
Valid values: github, azure, gerrit",
config_value.trim()
));
}
let remote_name = extract_remote_name(upstream_label);
if let Ok(remote) = repo.find_remote(&remote_name)
&& let Some(url) = remote.url()
{
if url.contains("github.com") {
return Ok(RemoteType::GitHub);
}
if url.contains("dev.azure.com") {
return Ok(RemoteType::AzureDevOps);
}
}
let hook_path = repo.commondir().join("hooks").join("commit-msg");
if let Ok(content) = std::fs::read_to_string(&hook_path)
&& content.to_lowercase().contains("gerrit")
{
let target_branch = extract_target_branch(upstream_label);
return Ok(RemoteType::Gerrit { target_branch });
}
Ok(RemoteType::Plain)
}
fn extract_remote_name(upstream_label: &str) -> String {
upstream_label
.split('/')
.next()
.unwrap_or("origin")
.to_string()
}
fn extract_gh_repo(repo: &Repository, remote: &str) -> Option<String> {
let remote = repo.find_remote(remote).ok()?;
let url = remote.url()?;
let scp_url = url.strip_prefix("git@").unwrap_or(url);
if !scp_url.contains("://")
&& let Some(colon_idx) = scp_url.find(':')
{
let host = &scp_url[..colon_idx];
if !host.contains('/') {
let path = &scp_url[colon_idx + 1..];
return Some(path.trim_end_matches(".git").to_string());
}
}
if let Some(path) = url
.strip_prefix("https://github.com/")
.or_else(|| url.strip_prefix("http://github.com/"))
{
return Some(path.trim_end_matches(".git").to_string());
}
None
}
fn extract_target_branch(upstream_label: &str) -> String {
let branch = repo::upstream_local_branch(upstream_label);
if branch.is_empty() {
"main".to_string()
} else {
branch
}
}
fn resolve_push_remote(
repo: &Repository,
workdir: &Path,
upstream_label: &str,
remote_type: &RemoteType,
) -> String {
if let Ok(push_remote) = git::run_git_stdout(workdir, &["config", "--get", "loom.push-remote"])
{
let remote = push_remote.trim();
if !remote.is_empty() && repo.find_remote(remote).is_ok() {
return remote.to_string();
}
}
let remote_name = extract_remote_name(upstream_label);
if *remote_type == RemoteType::GitHub
&& remote_name == "upstream"
&& repo.find_remote("origin").is_ok()
{
"origin".to_string()
} else {
remote_name
}
}
fn git_push(workdir: &Path, remote: &str, branch: &str) -> Result<()> {
git::run_git(
workdir,
&[
"push",
"--force-with-lease",
"--force-if-includes",
"-u",
remote,
branch,
],
)?;
msg::success(&format!("Pushed `{}` to `{}`", branch, remote));
Ok(())
}
fn gather_branch_commits(
repo: &Repository,
branch_name: &str,
base_oid: git2::Oid,
) -> Result<Vec<(String, String)>> {
let branch = repo.find_branch(branch_name, BranchType::Local)?;
let tip_oid = branch
.get()
.target()
.context("Branch does not point to a commit")?;
let mut revwalk = repo.revwalk()?;
revwalk.push(tip_oid)?;
revwalk.hide(base_oid)?;
revwalk.set_sorting(Sort::TOPOLOGICAL | Sort::REVERSE)?;
let mut commits = Vec::new();
for oid_result in revwalk {
let oid = oid_result?;
let commit = repo.find_commit(oid)?;
if commit.parent_count() > 1 {
continue; }
let subject = repo::commit_subject(&commit);
let body = commit.body().unwrap_or("").to_string();
commits.push((subject, body));
}
Ok(commits)
}
fn pr_title_and_description(
repo: &Repository,
branch_name: &str,
base_oid: git2::Oid,
) -> Result<(String, String)> {
let commits = gather_branch_commits(repo, branch_name, base_oid)?;
if commits.is_empty() {
return Ok((branch_name.to_string(), String::new()));
}
if commits.len() == 1 {
let (subject, body) = &commits[0];
return Ok((subject.clone(), body.clone()));
}
let title = msg::input("PR title", |s| {
if s.is_empty() {
Err("Title cannot be empty")
} else {
Ok(())
}
})?;
let description = commits
.iter()
.map(|(subject, body)| {
if body.is_empty() {
subject.clone()
} else {
format!("{}\n\n{}", subject, body)
}
})
.collect::<Vec<_>>()
.join("\n\n---\n\n");
Ok((title, description))
}
fn push_plain(workdir: &Path, remote: &str, branch: &str) -> Result<()> {
git_push(workdir, remote, branch)
}
fn push_github(
repo: &Repository,
workdir: &Path,
remote: &str,
branch: &str,
target_branch: &str,
base_oid: git2::Oid,
upstream_label: &str,
) -> Result<()> {
git_push(workdir, remote, branch)?;
if branch == target_branch {
return Ok(());
}
let start = Instant::now();
let gh_check = Command::new("gh").arg("--version").output();
let gh_available = gh_check.as_ref().is_ok_and(|o| o.status.success());
let duration_ms = start.elapsed().as_millis();
loom_trace::log_command("gh", "--version", duration_ms, gh_available, "");
if !gh_available {
msg::warn("Install 'gh' CLI to create pull requests: https://cli.github.com");
return Ok(());
}
let integration_remote = extract_remote_name(upstream_label);
let (pr_target_remote, pr_target_repo) = extract_gh_repo(repo, &integration_remote)
.map(|r| (integration_remote.as_str(), r))
.or_else(|| {
extract_gh_repo(repo, remote).map(|r| (remote, r))
})
.ok_or_else(|| {
anyhow::anyhow!(
"Could not determine target repository for PR creation\n\
Run `gh repo set-default` to select a default remote repository"
)
})?;
let is_fork = remote != pr_target_remote;
let head_arg = if is_fork {
extract_gh_repo(repo, remote)
.and_then(|r| r.split('/').next().map(|s| format!("{}:{}", s, branch)))
.unwrap_or_else(|| branch.to_string())
} else {
branch.to_string()
};
if let Some(pr_url) = find_existing_github_pr(workdir, &pr_target_repo, branch) {
msg::success(&format!("PR updated: {}", pr_url));
return Ok(());
}
let (title, body) = pr_title_and_description(repo, branch, base_oid)?;
let args = vec![
"pr",
"create",
"--web",
"--head",
&head_arg,
"--base",
target_branch,
"--repo",
&pr_target_repo,
"--title",
&title,
"--body",
&body,
];
let start = Instant::now();
let status = Command::new("gh")
.current_dir(workdir)
.args(&args)
.status()?;
let duration_ms = start.elapsed().as_millis();
loom_trace::log_command("gh", &args.join(" "), duration_ms, status.success(), "");
if !status.success() {
msg::warn("PR creation may have failed — check your browser");
}
Ok(())
}
fn find_existing_github_pr(workdir: &Path, gh_repo: &str, head_arg: &str) -> Option<String> {
let output = Command::new("gh")
.current_dir(workdir)
.args([
"pr", "list", "--head", head_arg, "--repo", gh_repo, "--json", "url", "--limit", "1",
])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
let trimmed = stdout.trim();
if trimmed == "[]" {
return None;
}
let prs: serde_json::Value = serde_json::from_str(trimmed).ok()?;
prs.get(0)?.get("url")?.as_str().map(str::to_string)
}
fn extract_azure_org_url(repo: &Repository, remote: &str) -> Option<String> {
let remote = repo.find_remote(remote).ok()?;
let url = remote.url()?;
if let Some(rest) = url.strip_prefix("https://dev.azure.com/") {
let org = rest.split('/').next()?;
return Some(format!("https://dev.azure.com/{}", org));
}
if let Some(rest) = url.strip_prefix("git@ssh.dev.azure.com:v3/") {
let org = rest.split('/').next()?;
return Some(format!("https://dev.azure.com/{}", org));
}
if let Some(rest) = url.strip_prefix("https://")
&& let Some(host) = rest.split('/').next()
&& host.ends_with(".visualstudio.com")
{
return Some(format!("https://{}", host));
}
None
}
fn az_command() -> Command {
if cfg!(windows) {
let mut cmd = Command::new("cmd");
cmd.args(["/C", "az"]);
cmd
} else {
Command::new("az")
}
}
fn push_azure(
repo: &Repository,
workdir: &Path,
remote: &str,
branch: &str,
target_branch: &str,
base_oid: git2::Oid,
) -> Result<()> {
git_push(workdir, remote, branch)?;
let start = Instant::now();
let az_check = az_command().arg("--version").output();
let az_available = az_check.as_ref().is_ok_and(|o| o.status.success());
let duration_ms = start.elapsed().as_millis();
loom_trace::log_command("az", "--version", duration_ms, az_available, "");
if !az_available {
msg::warn(
"Install 'az' CLI to create pull requests: \
https://learn.microsoft.com/cli/azure/install-azure-cli",
);
return Ok(());
}
let org_url = extract_azure_org_url(repo, remote);
if let Some(pr_url) = find_existing_azure_pr(workdir, branch, org_url.as_deref()) {
msg::success(&format!("PR updated: {}", pr_url));
return Ok(());
}
let (title, description) = pr_title_and_description(repo, branch, base_oid)?;
let mut desc_file = tempfile::Builder::new()
.suffix(".txt")
.tempfile()
.context("Failed to create temp file for PR description")?;
write!(desc_file, "{}", description).context("Failed to write PR description")?;
let desc_path = desc_file.path().to_string_lossy().into_owned();
let desc_arg = format!("@{}", desc_path);
let mut args: Vec<&str> = vec![
"repos",
"pr",
"create",
"--open",
"--source-branch",
branch,
"--target-branch",
target_branch,
"--title",
&title,
];
if let Some(ref org) = org_url {
args.push("--org");
args.push(org);
} else {
args.push("--detect");
}
if !description.is_empty() {
args.push("--description");
args.push(&desc_arg);
}
let start = Instant::now();
let output = az_command().current_dir(workdir).args(&args).output()?;
let duration_ms = start.elapsed().as_millis();
loom_trace::log_command(
"az",
&args.join(" "),
duration_ms,
output.status.success(),
"",
);
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
eprintln!("{}", stderr.trim());
}
Ok(())
}
fn find_existing_azure_pr(workdir: &Path, branch: &str, org_url: Option<&str>) -> Option<String> {
let mut cmd = az_command();
cmd.current_dir(workdir);
cmd.args([
"repos",
"pr",
"list",
"--source-branch",
branch,
"--output",
"json",
]);
if let Some(org) = org_url {
cmd.args(["--org", org]);
} else {
cmd.arg("--detect");
}
let output = cmd.output().ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
let trimmed = stdout.trim();
let prs: serde_json::Value = serde_json::from_str(trimmed).ok()?;
let pr = prs.get(0)?;
let repo_url = pr["repository"]["url"].as_str()?;
let org = repo_url
.strip_prefix("https://dev.azure.com/")?
.split('/')
.next()?;
let project = pr["repository"]["project"]["name"].as_str()?;
let repo = pr["repository"]["name"].as_str()?;
let pr_id = pr["pullRequestId"].as_u64()?;
Some(format!(
"https://dev.azure.com/{org}/{project}/_git/{repo}/pullrequest/{pr_id}"
))
}
fn push_gerrit_no_pr(workdir: &Path, remote: &str, branch: &str) -> Result<()> {
if branch.starts_with("wip/") {
return push_plain(workdir, remote, branch);
}
let opt_as_is = format!("Push as `{}` (admin required to delete it later)", branch);
let opt_wip = format!("Push as `wip/{}` instead", branch);
let choice = msg::select(
&format!(
"Branch `{}` is not prefixed with `wip/` — a Gerrit admin will be needed to delete the remote branch later",
branch
),
vec![opt_as_is.clone(), opt_wip.clone(), "Cancel".to_string()],
)?;
if choice == opt_as_is {
push_plain(workdir, remote, branch)
} else if choice == opt_wip {
let wip_name = format!("wip/{}", branch);
let refspec = format!("{}:{}", branch, wip_name);
git::run_git(
workdir,
&[
"push",
"--force-with-lease",
"--force-if-includes",
remote,
&refspec,
],
)?;
msg::success(&format!(
"Pushed `{}` to `{}` as `{}`",
branch, remote, wip_name
));
Ok(())
} else {
bail!("Cancelled")
}
}
fn push_gerrit(workdir: &Path, remote: &str, branch: &str, target_branch: &str) -> Result<()> {
let refspec = format!("{}:refs/for/{}", branch, target_branch);
let topic_opt = format!("topic={}", branch);
let args = ["push", "-o", &topic_opt, remote, &refspec];
let start = Instant::now();
let output = Command::new("git")
.current_dir(workdir)
.args(args)
.output()?;
let duration_ms = start.elapsed().as_millis();
let stderr = String::from_utf8_lossy(&output.stderr);
let cmd = args.join(" ");
loom_trace::log_command("git", &cmd, duration_ms, output.status.success(), &stderr);
if !output.status.success() {
bail!("git push failed");
}
let mut message = format!(
"Pushed `{}` to `{}` (Gerrit: `refs/for/{}`)",
branch, remote, target_branch
);
for line in stderr.lines() {
if let Some(rest) = line.strip_prefix("remote:") {
let trimmed = rest.trim();
if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
message.push('\n');
if trimmed.ends_with(']') {
if let Some(pos) = trimmed.rfind('[') {
let (before, tag) = trimmed.split_at(pos);
message.push_str(&format!("{}`{}`", before, tag));
} else {
message.push_str(trimmed);
}
} else {
message.push_str(trimmed);
}
}
}
}
msg::success(&message);
Ok(())
}
#[cfg(test)]
#[path = "push_test.rs"]
mod tests;