use crate::cmd::CommandErrorContext;
use crate::github::http::GhHttp;
use regex::Regex;
use std::path::Path;
use std::sync::LazyLock;
use tokio::process::Command;
pub(crate) fn format_task_ref(task_id: &str) -> String {
if task_id.starts_with("internal:") {
task_id.replace(':', "-")
} else {
format!("#{task_id}")
}
}
#[derive(Debug, thiserror::Error)]
pub enum PrCreateError {
#[error("GitHub API error: {0}")]
ApiError(#[from] anyhow::Error),
}
pub type PrCreateResult<T> = Result<T, PrCreateError>;
pub async fn has_commits_ahead(dir: &Path, default_branch: &str) -> bool {
let output = Command::new("git")
.args([
"rev-list",
"--count",
&format!("origin/{default_branch}..HEAD"),
])
.current_dir(dir)
.output()
.await;
match output {
Ok(o) if o.status.success() => {
let count: u32 = String::from_utf8_lossy(&o.stdout)
.trim()
.parse()
.unwrap_or(0);
count > 0
}
_ => {
tracing::debug!(
default_branch,
"git rev-list failed for compare ref, assuming commits ahead"
);
true
}
}
}
pub async fn has_changes(dir: &Path) -> bool {
let diff = Command::new("git")
.args(["diff", "--quiet"])
.current_dir(dir)
.status()
.await;
let cached = Command::new("git")
.args(["diff", "--cached", "--quiet"])
.current_dir(dir)
.status()
.await;
let untracked = Command::new("git")
.args(["ls-files", "--others", "--exclude-standard"])
.current_dir(dir)
.output_with_context()
.await;
let has_diff = diff.map(|s| !s.success()).unwrap_or(false);
let has_cached = cached.map(|s| !s.success()).unwrap_or(false);
let has_untracked = untracked
.map(|o| !String::from_utf8_lossy(&o.stdout).trim().is_empty())
.unwrap_or(false);
has_diff || has_cached || has_untracked
}
pub(crate) fn find_stash_ref_by_hash(stash_list: &str, stash_hash: &str) -> Option<String> {
stash_list.lines().find_map(|line| {
let mut parts = line.split_whitespace();
match (parts.next(), parts.next()) {
(Some(hash), Some(reference)) if hash == stash_hash => Some(reference.to_string()),
_ => None,
}
})
}
pub(crate) struct StashRebaseOpts {
pub disable_gpg_signing: bool,
pub abort_on_failure: bool,
}
impl Default for StashRebaseOpts {
fn default() -> Self {
Self {
disable_gpg_signing: false,
abort_on_failure: true,
}
}
}
#[derive(Debug)]
pub(crate) enum RebaseOutcome {
Succeeded,
Failed(String),
Skipped(String),
}
pub(crate) async fn stash_rebase_restore(
dir: &Path,
target_ref: &str,
opts: StashRebaseOpts,
) -> anyhow::Result<RebaseOutcome> {
let stash_ref: Option<String> = if has_changes(dir).await {
let stash_out = Command::new("git")
.args(["stash", "push", "--include-untracked", "-m", "orch-rebase"])
.current_dir(dir)
.output_with_context()
.await;
match stash_out {
Ok(o) if o.status.success() => {
let stdout_preview = String::from_utf8_lossy(&o.stdout)
.lines()
.next()
.unwrap_or("")
.to_string();
tracing::debug!(
dir = %dir.display(),
stdout = %stdout_preview,
"git stash push succeeded before rebase"
);
let ref_out = Command::new("git")
.args(["rev-parse", "refs/stash@{0}"])
.current_dir(dir)
.output_with_context()
.await;
let stash_hash = ref_out
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.filter(|s| !s.is_empty());
if stash_hash.is_none() {
tracing::warn!(
dir = %dir.display(),
"git stash succeeded but rev-parse failed — popping stash and skipping rebase"
);
let _ = Command::new("git")
.args(["stash", "pop"])
.current_dir(dir)
.output_with_context()
.await;
return Ok(RebaseOutcome::Skipped(
"stash OID capture failed".to_string(),
));
}
stash_hash
}
Ok(o) => {
let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
tracing::warn!(
dir = %dir.display(),
code = ?o.status.code(),
stderr = %stderr,
"git stash push failed — skipping rebase to preserve dirty state"
);
return Ok(RebaseOutcome::Skipped(format!(
"stash push failed: {stderr}"
)));
}
Err(e) => {
tracing::warn!(
dir = %dir.display(),
error = %e,
"failed to run git stash push — skipping rebase to preserve dirty state"
);
return Ok(RebaseOutcome::Skipped(format!("stash push error: {e}")));
}
}
} else {
None
};
let mut rebase_args: Vec<&str> = Vec::new();
if opts.disable_gpg_signing {
rebase_args.extend(["-c", "commit.gpgsign=false"]);
}
rebase_args.extend(["rebase", target_ref]);
let output = Command::new("git")
.args(&rebase_args)
.current_dir(dir)
.output_with_context()
.await;
match output {
Ok(o) if o.status.success() => {
tracing::debug!(dir = %dir.display(), target_ref, "rebase succeeded");
restore_stash_by_hash(dir, stash_ref.as_deref()).await;
Ok(RebaseOutcome::Succeeded)
}
Ok(o) => {
let stderr = String::from_utf8_lossy(&o.stderr).to_string();
tracing::warn!(dir = %dir.display(), target_ref, err = %stderr, "rebase failed");
if opts.abort_on_failure {
let abort = Command::new("git")
.args(["rebase", "--abort"])
.current_dir(dir)
.output_with_context()
.await;
match abort {
Err(e) => {
tracing::warn!(dir = %dir.display(), error = %e, "git rebase --abort failed — worktree may be in inconsistent state")
}
Ok(o) if !o.status.success() => {
let stderr = String::from_utf8_lossy(&o.stderr);
tracing::warn!(dir = %dir.display(), stderr = %stderr, "git rebase --abort returned non-zero — worktree may be in inconsistent state");
}
_ => {}
}
}
restore_stash_by_hash(dir, stash_ref.as_deref()).await;
Ok(RebaseOutcome::Failed(stderr))
}
Err(e) => {
if opts.abort_on_failure {
let abort = Command::new("git")
.args(["rebase", "--abort"])
.current_dir(dir)
.output_with_context()
.await;
match abort {
Err(e) => {
tracing::warn!(dir = %dir.display(), error = %e, "git rebase --abort failed — worktree may be in inconsistent state")
}
Ok(o) if !o.status.success() => {
let stderr = String::from_utf8_lossy(&o.stderr);
tracing::warn!(dir = %dir.display(), stderr = %stderr, "git rebase --abort returned non-zero — worktree may be in inconsistent state");
}
_ => {}
}
}
restore_stash_by_hash(dir, stash_ref.as_deref()).await;
Err(e)
}
}
}
async fn restore_stash_by_hash(dir: &Path, stash_hash: Option<&str>) {
let Some(stash_hash) = stash_hash else {
return;
};
let apply = Command::new("git")
.args(["stash", "apply", stash_hash])
.current_dir(dir)
.output_with_context()
.await;
if apply.map(|o| o.status.success()).unwrap_or(false) {
let list = Command::new("git")
.args(["stash", "list", "--format=%H %gd"])
.current_dir(dir)
.output_with_context()
.await;
if let Ok(list_out) = list {
if list_out.status.success() {
let list_str = String::from_utf8_lossy(&list_out.stdout);
if let Some(stash_ref) = find_stash_ref_by_hash(&list_str, stash_hash) {
match Command::new("git")
.args(["stash", "drop", &stash_ref])
.current_dir(dir)
.output_with_context()
.await
{
Ok(drop_out) if !drop_out.status.success() => {
let stderr = String::from_utf8_lossy(&drop_out.stderr);
tracing::warn!(
dir = %dir.display(),
stash_ref = %stash_ref,
err = %stderr,
"stash drop failed after apply — entry may remain on stack"
);
}
Err(e) => {
tracing::warn!(
dir = %dir.display(),
stash_ref = %stash_ref,
error = %e,
"stash drop command failed after apply — entry may remain on stack"
);
}
_ => {}
}
} else {
tracing::warn!(
dir = %dir.display(),
stash = %stash_hash,
"stash applied but could not find ref by hash — stash entry may be orphaned on stack"
);
}
}
}
} else {
tracing::warn!(
dir = %dir.display(),
stash = %stash_hash,
"stash apply failed after rebase — stash preserved for manual recovery"
);
}
}
fn git_identity(agent: &str) -> (String, String) {
let name = crate::config::get("git.name").unwrap_or_else(|_| format!("{agent}[bot]"));
let email = crate::config::get("git.email")
.unwrap_or_else(|_| format!("{agent}[bot]@users.noreply.github.com"));
(name, email)
}
pub async fn auto_commit(
dir: &Path,
task_id: &str,
title: &str,
agent: &str,
attempt: u32,
) -> anyhow::Result<bool> {
if !has_changes(dir).await {
return Ok(false);
}
tracing::info!(task_id, "auto-committing uncommitted changes");
let task_ref = format_task_ref(task_id);
let commit_msg = format!("{title}\n\nTask {task_ref}\nAgent: {agent}\nAttempt: {attempt}");
let add = Command::new("git")
.args(["add", "-A"])
.current_dir(dir)
.output_with_context()
.await?;
if !add.status.success() {
tracing::warn!(task_id, "git add -A failed");
return Ok(false);
}
let (git_name, git_email) = git_identity(agent);
let commit = Command::new("git")
.args(["-c", "commit.gpgsign=false", "commit", "-m", &commit_msg])
.env("GIT_AUTHOR_NAME", &git_name)
.env("GIT_COMMITTER_NAME", &git_name)
.env("GIT_AUTHOR_EMAIL", &git_email)
.env("GIT_COMMITTER_EMAIL", &git_email)
.current_dir(dir)
.output_with_context()
.await?;
if !commit.status.success() {
let stderr = String::from_utf8_lossy(&commit.stderr);
tracing::warn!(task_id, err = %stderr, "git commit failed");
let restore = Command::new("git")
.args(["restore", "--staged", "."])
.current_dir(dir)
.output_with_context()
.await;
match restore {
Ok(o) if o.status.success() => {}
Ok(o) => {
let restore_stderr = String::from_utf8_lossy(&o.stderr).to_string();
tracing::warn!(
task_id,
stderr = %restore_stderr,
"git restore --staged failed after commit failure; falling back to git reset HEAD -- ."
);
let reset = Command::new("git")
.args(["reset", "HEAD", "--", "."])
.current_dir(dir)
.output_with_context()
.await?;
if !reset.status.success() {
anyhow::bail!(
"git commit failed and cleanup failed (git restore --staged + git reset HEAD -- .): commit stderr: {}; restore stderr: {}; reset stderr: {}",
stderr.trim(),
restore_stderr.trim(),
String::from_utf8_lossy(&reset.stderr).trim()
);
}
}
Err(e) => {
tracing::warn!(
task_id,
error = %e,
"git restore --staged command failed after commit failure; falling back to git reset HEAD -- ."
);
let reset = Command::new("git")
.args(["reset", "HEAD", "--", "."])
.current_dir(dir)
.output_with_context()
.await?;
if !reset.status.success() {
anyhow::bail!(
"git commit failed and cleanup failed (git restore --staged command error + git reset HEAD -- .): commit stderr: {}; restore error: {}; reset stderr: {}",
stderr.trim(),
e,
String::from_utf8_lossy(&reset.stderr).trim()
);
}
}
}
return Ok(false);
}
tracing::info!(task_id, "auto-commit succeeded");
Ok(true)
}
const MAX_REBASE_COMMITS: usize = 50;
pub async fn rebase_on_branch(dir: &Path, branch: &str) -> anyhow::Result<bool> {
let origin_branch = format!("origin/{branch}");
let fetch = tokio::time::timeout(
std::time::Duration::from_secs(120),
Command::new("git")
.args(["fetch", "origin", branch])
.kill_on_drop(true)
.current_dir(dir)
.output(),
)
.await
.map_err(|_| anyhow::anyhow!("git fetch timed out after 120s"))
.and_then(|r| r.map_err(Into::into));
match fetch {
Ok(o) if o.status.success() => {}
Ok(o) => {
let stderr = String::from_utf8_lossy(&o.stderr);
anyhow::bail!("failed to fetch origin/{branch}: {stderr}");
}
Err(e) => anyhow::bail!("failed to fetch origin/{branch}: {e}"),
}
let count_output = Command::new("git")
.args(["rev-list", "--count", &format!("{origin_branch}..HEAD")])
.current_dir(dir)
.output_with_context()
.await;
let commit_count: usize = match count_output {
Ok(o) if o.status.success() => {
let raw = String::from_utf8_lossy(&o.stdout);
raw.trim().parse().map_err(|e| {
anyhow::anyhow!(
"failed to parse commit count from git rev-list output {:?}: {e}",
raw.trim()
)
})?
}
Ok(o) => {
let stderr = String::from_utf8_lossy(&o.stderr);
anyhow::bail!("git rev-list --count failed: {stderr}");
}
Err(e) => anyhow::bail!("failed to run git rev-list --count: {e}"),
};
if commit_count == 0 {
return Ok(false);
}
if commit_count > MAX_REBASE_COMMITS {
anyhow::bail!(
"refusing to rebase {commit_count} commits on origin/{branch} (max {})",
MAX_REBASE_COMMITS
);
}
tracing::info!(
branch = branch,
commit_count,
"rebasing local commits on origin/{branch}"
);
match stash_rebase_restore(dir, &origin_branch, StashRebaseOpts::default()).await? {
RebaseOutcome::Succeeded => Ok(true),
RebaseOutcome::Failed(e) => anyhow::bail!("rebase failed: {e}"),
RebaseOutcome::Skipped(reason) => anyhow::bail!("rebase skipped: {reason}"),
}
}
pub async fn rebase_on_default(dir: &Path, default_branch: &str) {
if rebase_in_progress(dir).await {
tracing::info!("rebase already in progress, skipping pre-dispatch rebase");
return;
}
let _ = Command::new("git")
.args(["fetch", "origin"])
.current_dir(dir)
.output_with_context()
.await;
let commit_count = Command::new("git")
.args([
"rev-list",
"--count",
&format!("origin/{default_branch}..HEAD"),
])
.current_dir(dir)
.output_with_context()
.await
.ok()
.and_then(|o| {
String::from_utf8_lossy(&o.stdout)
.trim()
.parse::<usize>()
.ok()
})
.unwrap_or(0);
if commit_count > MAX_REBASE_COMMITS {
tracing::warn!(
default_branch,
commit_count,
max = MAX_REBASE_COMMITS,
"skipping rebase: branch has too many commits to replay safely"
);
return;
}
match stash_rebase_restore(
dir,
&format!("origin/{default_branch}"),
StashRebaseOpts::default(),
)
.await
{
Ok(RebaseOutcome::Succeeded) => {
tracing::debug!(default_branch, "rebased worktree on default branch")
}
Ok(RebaseOutcome::Failed(e)) => {
tracing::warn!(default_branch, err = %e, "rebase failed, continuing with current state")
}
Ok(RebaseOutcome::Skipped(reason)) => {
tracing::warn!(default_branch, %reason, "stash failed before rebase, continuing with current state")
}
Err(e) => tracing::warn!(err = %e, "rebase error"),
}
}
async fn rebase_in_progress(dir: &Path) -> bool {
for name in ["rebase-merge", "rebase-apply"] {
let out = Command::new("git")
.args(["rev-parse", "--git-path", name])
.current_dir(dir)
.output_with_context()
.await;
if let Ok(o) = out {
let p = String::from_utf8_lossy(&o.stdout).trim().to_string();
if !p.is_empty() && dir.join(p).exists() {
return true;
}
}
}
false
}
pub(crate) fn build_git_auth_env() -> Vec<(String, String)> {
let token = crate::github::token::TokenResolver::default_env()
.get_token_sync()
.ok()
.flatten();
build_git_auth_env_for_token(token)
}
fn build_git_auth_env_for_token(token: Option<String>) -> Vec<(String, String)> {
match token {
Some(t) if !t.is_empty() => {
let authed = format!("url.https://x-access-token:{t}@github.com/.insteadOf");
vec![
("GIT_CONFIG_COUNT".into(), "2".into()),
("GIT_CONFIG_KEY_0".into(), authed.clone()),
("GIT_CONFIG_VALUE_0".into(), "https://github.com/".into()),
("GIT_CONFIG_KEY_1".into(), authed),
("GIT_CONFIG_VALUE_1".into(), "git@github.com:".into()),
]
}
_ => {
vec![
("GIT_CONFIG_COUNT".into(), "1".into()),
(
"GIT_CONFIG_KEY_0".into(),
"url.https://github.com/.insteadOf".into(),
),
("GIT_CONFIG_VALUE_0".into(), "git@github.com:".into()),
]
}
}
}
pub(crate) fn is_workflow_scope_error(stderr: &str) -> bool {
stderr.contains("without `workflow` scope")
|| stderr.contains("without 'workflow' scope")
|| (stderr.contains("refusing to allow")
&& stderr.contains("workflow")
&& stderr.contains("scope"))
}
async fn strip_workflow_files(dir: &Path, default_branch: &str) -> anyhow::Result<bool> {
let output = Command::new("git")
.args([
"diff",
"--name-only",
&format!("origin/{default_branch}...HEAD"),
"--",
".github/workflows/",
])
.current_dir(dir)
.output_with_context()
.await?;
let workflow_files: Vec<String> = String::from_utf8_lossy(&output.stdout)
.lines()
.filter(|l| !l.is_empty())
.map(|s| s.to_string())
.collect();
if workflow_files.is_empty() {
return Ok(false);
}
tracing::info!(
files = ?workflow_files,
"stripping workflow files from commits to bypass missing workflow scope"
);
let count_output = Command::new("git")
.args([
"rev-list",
"--count",
&format!("origin/{default_branch}..HEAD"),
])
.current_dir(dir)
.output_with_context()
.await?;
let commit_count: usize = String::from_utf8_lossy(&count_output.stdout)
.trim()
.parse()
.unwrap_or(0);
if commit_count == 0 {
return Ok(false);
}
let log_output = Command::new("git")
.args([
"log",
"--format=%x00%H%n%B",
&format!("origin/{default_branch}..HEAD"),
])
.current_dir(dir)
.output_with_context()
.await?;
let log_text = String::from_utf8_lossy(&log_output.stdout).to_string();
let commits: Vec<(String, String)> = log_text
.split('\0')
.filter(|s| !s.trim().is_empty())
.map(|block| {
let block = block.trim();
let first_nl = block.find('\n').unwrap_or(block.len());
let hash = block[..first_nl].to_string();
let msg = block[first_nl..].trim().to_string();
(hash, msg)
})
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
if commits.is_empty() {
return Ok(false);
}
let reset = Command::new("git")
.args(["reset", "--soft", &format!("origin/{default_branch}")])
.current_dir(dir)
.output_with_context()
.await?;
if !reset.status.success() {
let stderr = String::from_utf8_lossy(&reset.stderr);
anyhow::bail!("git reset --soft failed during workflow file stripping: {stderr}");
}
let mut rm_args = vec!["rm", "--cached", "--ignore-unmatch", "--"];
let file_refs: Vec<&str> = workflow_files.iter().map(|s| s.as_str()).collect();
rm_args.extend(file_refs);
let rm = Command::new("git")
.args(&rm_args)
.current_dir(dir)
.output_with_context()
.await?;
if !rm.status.success() {
let stderr = String::from_utf8_lossy(&rm.stderr);
tracing::warn!("git rm --cached for workflow files failed (non-fatal): {stderr}");
}
let has_staged = Command::new("git")
.args(["diff", "--cached", "--quiet"])
.current_dir(dir)
.status()
.await
.map(|s| !s.success())
.unwrap_or(false);
if !has_staged {
tracing::warn!(
"after removing workflow files, no staged changes remain — \
the only changes were workflow files"
);
if let Some((hash, _)) = commits.last() {
let _ = Command::new("git")
.args(["reset", "--soft", hash])
.current_dir(dir)
.output_with_context()
.await;
}
return Ok(false);
}
let commit_msg = if commits.len() == 1 {
commits[0].1.clone()
} else {
let mut combined = String::new();
for (i, (_, msg)) in commits.iter().enumerate() {
if i > 0 {
combined.push_str("\n\n");
}
combined.push_str(msg);
}
combined.push_str("\n\n[workflow files stripped: token lacks `workflow` scope]");
combined
};
let (git_name, git_email) = git_identity("orchestrator");
let commit = Command::new("git")
.args(["-c", "commit.gpgsign=false", "commit", "-m", &commit_msg])
.env("GIT_AUTHOR_NAME", &git_name)
.env("GIT_COMMITTER_NAME", &git_name)
.env("GIT_AUTHOR_EMAIL", &git_email)
.env("GIT_COMMITTER_EMAIL", &git_email)
.current_dir(dir)
.output_with_context()
.await?;
if !commit.status.success() {
let stderr = String::from_utf8_lossy(&commit.stderr);
if let Some((original_head, _)) = commits.last() {
let _ = Command::new("git")
.args(["reset", "--hard", original_head])
.current_dir(dir)
.output_with_context()
.await;
}
anyhow::bail!("git commit failed during workflow file stripping: {stderr}");
}
tracing::info!(
stripped_files = ?workflow_files,
original_commits = commit_count,
"successfully stripped workflow files from commits"
);
Ok(true)
}
pub async fn push_branch(dir: &Path, branch: &str, default_branch: &str) -> anyhow::Result<bool> {
let current = get_current_branch(dir).await;
let branch_to_push = if !current.is_empty() {
¤t
} else {
branch
};
if branch_to_push.is_empty() {
anyhow::bail!("cannot push: branch name is empty");
}
if branch_to_push == "main" || branch_to_push == "master" {
return Ok(false);
}
let (remote_exists, has_unpushed) =
check_push_needed(dir, branch_to_push, default_branch).await;
if remote_exists && !has_unpushed {
tracing::debug!(
branch = branch_to_push,
"remote branch exists and is up to date, skipping push"
);
return Ok(true);
}
if !remote_exists && !has_unpushed {
tracing::debug!(
branch = branch_to_push,
"remote branch missing with no new commits — pushing to create it on remote"
);
}
tracing::info!(branch = branch_to_push, "pushing branch");
let auth_env = build_git_auth_env();
let output = tokio::time::timeout(
std::time::Duration::from_secs(120),
Command::new("git")
.args([
"-c",
"lfs.locksverify=false",
"push",
"-u",
"origin",
branch_to_push,
])
.kill_on_drop(true)
.envs(auth_env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
.current_dir(dir)
.output(),
)
.await
.map_err(|_| anyhow::anyhow!("git push timed out after 120s"))??;
if output.status.success() {
tracing::info!(branch = branch_to_push, "push succeeded");
return Ok(true);
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if is_transient_network_error(&stderr) {
tracing::warn!(
branch = branch_to_push,
stderr = %sanitize_push_error(&stderr),
"push failed with transient network error — retrying once"
);
let retry_output = tokio::time::timeout(
std::time::Duration::from_secs(120),
Command::new("git")
.args([
"-c",
"lfs.locksverify=false",
"push",
"-u",
"origin",
branch_to_push,
])
.kill_on_drop(true)
.envs(auth_env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
.current_dir(dir)
.output(),
)
.await
.map_err(|_| anyhow::anyhow!("git push (network retry) timed out after 120s"))??;
if retry_output.status.success() {
tracing::info!(branch = branch_to_push, "push succeeded on network retry");
return Ok(true);
}
let retry_stderr = String::from_utf8_lossy(&retry_output.stderr)
.trim()
.to_string();
tracing::warn!(
branch = branch_to_push,
stderr = %sanitize_push_error(&retry_stderr),
"push retry also failed"
);
anyhow::bail!("push failed: {}", sanitize_push_error(&retry_stderr));
}
if is_workflow_scope_error(&stderr) {
tracing::warn!(
branch = branch_to_push,
"push rejected: token lacks `workflow` scope — stripping workflow files and retrying"
);
match strip_workflow_files(dir, default_branch).await {
Ok(true) => {
let retry_output = tokio::time::timeout(
std::time::Duration::from_secs(120),
Command::new("git")
.args([
"-c",
"lfs.locksverify=false",
"push",
"-u",
"origin",
branch_to_push,
])
.kill_on_drop(true)
.envs(auth_env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
.current_dir(dir)
.output(),
)
.await
.map_err(|_| anyhow::anyhow!("git push (retry) timed out after 120s"))??;
if retry_output.status.success() {
tracing::info!(
branch = branch_to_push,
"push succeeded after stripping workflow files"
);
return Ok(true);
}
let retry_stderr = String::from_utf8_lossy(&retry_output.stderr)
.trim()
.to_string();
if is_workflow_scope_error(&retry_stderr) {
anyhow::bail!(
"push failed: token lacks `workflow` OAuth scope and workflow files \
could not be fully stripped — add `workflow` scope to your GitHub \
token or use a GitHub App for authentication"
);
}
anyhow::bail!("push failed after stripping workflow files: {retry_stderr}");
}
Ok(false) => {
anyhow::bail!(
"push failed: token lacks `workflow` OAuth scope — add `workflow` scope \
to your GitHub token or use a GitHub App for authentication. \
Error: {stderr}"
);
}
Err(e) => {
tracing::error!(error = %e, "failed to strip workflow files from commits");
anyhow::bail!(
"push failed: token lacks `workflow` OAuth scope and automatic \
recovery failed ({e}) — add `workflow` scope to your GitHub token \
or use a GitHub App for authentication"
);
}
}
}
if push_needs_rebase(&stderr) && remote_exists {
tracing::info!(
branch = branch_to_push,
"push rejected (non-fast-forward), force-pushing with lease"
);
let output = Command::new("git")
.args([
"-c",
"lfs.locksverify=false",
"push",
"--force-with-lease",
"-u",
"origin",
branch_to_push,
])
.envs(auth_env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
.current_dir(dir)
.output_with_context()
.await?;
if output.status.success() {
tracing::info!(branch = branch_to_push, "force push succeeded");
return Ok(true);
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if is_workflow_scope_error(&stderr) {
tracing::warn!(
branch = branch_to_push,
"force push also rejected due to workflow scope — stripping and retrying"
);
match strip_workflow_files(dir, default_branch).await {
Ok(true) => {
let retry_output = Command::new("git")
.args([
"-c",
"lfs.locksverify=false",
"push",
"--force-with-lease",
"-u",
"origin",
branch_to_push,
])
.envs(auth_env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
.current_dir(dir)
.output_with_context()
.await?;
if retry_output.status.success() {
tracing::info!(
branch = branch_to_push,
"force push succeeded after stripping workflow files"
);
return Ok(true);
}
let retry_stderr = String::from_utf8_lossy(&retry_output.stderr)
.trim()
.to_string();
anyhow::bail!(
"force push failed after stripping workflow files: {retry_stderr}"
);
}
Ok(false) => {
anyhow::bail!(
"force push failed: token lacks `workflow` OAuth scope — \
add `workflow` scope to your GitHub token or use a GitHub App. \
Error: {stderr}"
);
}
Err(e) => {
anyhow::bail!(
"force push failed: token lacks `workflow` scope and recovery \
failed ({e})"
);
}
}
}
anyhow::bail!("force push failed: {stderr}");
}
anyhow::bail!("push failed: {stderr}")
}
fn push_needs_rebase(stderr: &str) -> bool {
let lower = stderr.to_ascii_lowercase();
lower.contains("non-fast-forward")
|| lower.contains("rejected")
|| lower.contains("fetch first")
|| lower.contains("behind")
}
fn is_transient_network_error(stderr: &str) -> bool {
let lower = stderr.to_ascii_lowercase();
lower.contains("i/o timeout")
|| lower.contains("dial tcp")
|| lower.contains("connection reset")
|| lower.contains("connection refused")
|| lower.contains("no route to host")
|| lower.contains("temporary failure in name resolution")
|| lower.contains("eof")
}
fn sanitize_push_error(stderr: &str) -> String {
static URL_USERINFO: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"://[^/@\s]+@").expect("valid url userinfo regex"));
let no_userinfo = URL_USERINFO.replace_all(stderr, "://***@");
crate::security::redact(&no_userinfo)
}
#[allow(clippy::too_many_arguments)]
pub async fn create_pr_if_needed(
dir: &Path,
branch: &str,
title: &str,
summary: &str,
accomplished: &[String],
remaining: &[String],
files: &[String],
task_id: &str,
agent: &str,
model: Option<&str>,
repo: &str,
base_branch: &str,
) -> PrCreateResult<String> {
let gh = GhHttp::new()?;
match gh.get_pr_number(repo, branch).await {
Ok(Some(pr_number)) => {
tracing::info!(task_id, pr = pr_number, "PR already exists");
append_pr_footer_if_missing(&gh, repo, pr_number, task_id, agent, model).await;
let pr = match gh.get_pr(repo, pr_number).await {
Ok(pr) => pr,
Err(e) => {
tracing::warn!(task_id, error = %e, "get_pr API call failed after detecting existing PR");
return Err(PrCreateError::ApiError(e));
}
};
return Ok(pr.html_url);
}
Ok(None) => {
}
Err(e) => {
tracing::warn!(task_id, error = %e, "get_pr_number API call failed");
return Err(PrCreateError::ApiError(e));
}
}
let mut body = format!(
"## Summary\n\n{}",
if summary.is_empty() { title } else { summary }
);
if !accomplished.is_empty() {
body.push_str("\n\n### What was done\n\n");
for item in accomplished {
body.push_str(&format!("- {item}\n"));
}
}
if !remaining.is_empty() {
body.push_str("\n\n### Remaining\n\n");
for item in remaining {
body.push_str(&format!("- {item}\n"));
}
}
let git_files: Vec<String>;
let effective_files: &[String] = if files.is_empty() {
match tokio::process::Command::new("git")
.args([
"diff",
&format!("origin/{base_branch}...HEAD"),
"--name-only",
])
.current_dir(dir)
.output()
.await
{
Ok(output) if output.status.success() => {
git_files = String::from_utf8_lossy(&output.stdout)
.lines()
.filter(|l| !l.is_empty())
.map(|l| l.to_string())
.collect();
&git_files
}
_ => files,
}
} else {
files
};
if !effective_files.is_empty() {
body.push_str("\n\n### Files changed\n\n");
for file in effective_files {
body.push_str(&format!("- `{file}`\n"));
}
}
if !task_id.starts_with("internal:") {
if let Ok(issue_num) = task_id.parse::<u64>() {
body.push_str(&format!("\n\nCloses #{issue_num}"));
}
}
let model_str = model.map(|m| format!(" using `{m}`")).unwrap_or_default();
let task_ref = format_task_ref(task_id);
body.push_str(&format!(
"\n\n---\n*Task {task_ref} · Created by {agent}[bot] via [Orch](https://github.com/gabrielkoerich/orch){model_str}*"
));
let pr_title = title;
const MAX_RETRIES: u32 = 3;
let mut last_error: Option<anyhow::Error> = None;
let mut created_url: Option<String> = None;
let mut had_transient_error = false;
let mut effective_base = base_branch.to_string();
let mut tried_api_fallback = false;
for attempt in 1..=MAX_RETRIES {
match gh
.create_pr(repo, pr_title, &body, branch, &effective_base)
.await
{
Ok(u) => {
created_url = Some(u);
break;
}
Err(e) => {
let err_str = format!("{e}");
if is_transient_github_error(&err_str) {
had_transient_error = true;
if attempt < MAX_RETRIES {
let delay = std::time::Duration::from_secs(2u64.pow(attempt));
tracing::warn!(
task_id,
attempt,
delay_secs = delay.as_secs(),
error = %e,
"transient GitHub API error during PR creation — retrying"
);
tokio::time::sleep(delay).await;
}
} else if is_invalid_base_error(&err_str) && !tried_api_fallback {
tried_api_fallback = true;
tracing::warn!(
task_id,
base_branch = %effective_base,
error = %e,
"PR creation failed with invalid base — fetching default branch from GitHub API"
);
match gh.get_default_branch(repo).await {
Ok(api_default) if api_default != effective_base => {
tracing::info!(
task_id,
old_base = %effective_base,
new_base = %api_default,
"retrying PR creation with GitHub API default branch"
);
effective_base = api_default;
continue;
}
Ok(api_default) => {
tracing::warn!(
task_id,
base_branch = %api_default,
"GitHub API default branch matches local — no fallback possible"
);
}
Err(api_err) => {
tracing::warn!(
task_id,
error = %api_err,
"failed to fetch default branch from GitHub API"
);
}
}
}
last_error = Some(e);
}
}
}
let url = match created_url {
Some(u) => u,
None => {
let e = last_error.unwrap_or_else(|| {
anyhow::anyhow!("PR creation failed after retries (no error captured)")
});
let err_str = format!("{e}");
if is_transient_github_error(&err_str) || had_transient_error {
tracing::warn!(
task_id,
error = %e,
"transient GitHub API error after all retries — checking if PR was actually created"
);
match gh.get_pr_number(repo, branch).await {
Ok(Some(pr_number)) => {
tracing::info!(
task_id,
pr = pr_number,
"PR was created despite transient errors — recovering"
);
append_pr_footer_if_missing(&gh, repo, pr_number, task_id, agent, model)
.await;
match gh.get_pr(repo, pr_number).await {
Ok(pr) => pr.html_url,
Err(get_err) => {
tracing::warn!(
task_id,
error = %get_err,
"failed to fetch PR URL after transient-error recovery"
);
return Err(PrCreateError::ApiError(e));
}
}
}
Ok(None) => {
tracing::warn!(
task_id,
"PR was not created after all retries — propagating original error"
);
return Err(PrCreateError::ApiError(e));
}
Err(check_err) => {
tracing::warn!(
task_id,
error = %check_err,
"failed to verify PR existence after transient error retries — propagating original error"
);
return Err(PrCreateError::ApiError(e));
}
}
} else {
return Err(PrCreateError::ApiError(e));
}
}
};
tracing::info!(task_id, pr_url = %url, "created PR via GhHttp API");
Ok(url)
}
pub(crate) fn is_transient_github_error(err_str: &str) -> bool {
if err_str.contains("HTTP send failed") {
return true;
}
if err_str.contains("GitHub API server error") {
return true;
}
if extract_github_http_status(err_str)
.map(|s| (500..600).contains(&s))
.unwrap_or(false)
{
return true;
}
let lower = err_str.to_lowercase();
if lower.contains("connection reset")
|| lower.contains("broken pipe")
|| lower.contains("connection refused")
|| lower.contains("connection closed")
|| lower.contains("transport error")
|| lower.contains("failed to connect")
|| lower.contains("failed to connect to github.com port")
|| lower.contains("curl: (7) failed to connect")
|| lower.contains("couldn't connect to server")
|| lower.contains("could not connect to server")
{
return true;
}
if lower.contains("timeout") {
return true;
}
if lower.contains("dns error")
|| lower.contains("resolve")
|| lower.contains("name resolution")
|| lower.contains("no such host")
{
return true;
}
if lower.contains("tls handshake")
|| lower.contains("certificate")
|| lower.contains("ssl error")
{
return true;
}
if lower.contains("network is unreachable")
|| lower.contains("host unreachable")
|| lower.contains("temporary failure")
{
return true;
}
if lower.contains("unexpected eof") || lower.contains("unexpected end of file") {
return true;
}
if lower.contains("circuit-breaker") || lower.contains("circuit breaker") {
return true;
}
if lower.contains("rate limit") || lower.contains("too many requests") {
return true;
}
false
}
pub(crate) fn is_invalid_base_error(err_str: &str) -> bool {
let lower = err_str.to_lowercase();
lower.contains("422") && lower.contains("base") && lower.contains("invalid")
}
pub(crate) fn is_transient_github_api_error(err_str: &str) -> bool {
if !is_transient_github_error(err_str) {
return false;
}
if extract_github_http_status(err_str).is_some() {
return true;
}
let lower = err_str.to_lowercase();
lower.contains("github")
|| lower.contains("api.github.com")
|| lower.contains("gh api")
|| lower.contains("gh pr")
|| lower.contains("gh error")
|| lower.contains("circuit-breaker")
|| lower.contains("circuit breaker")
}
fn extract_github_http_status(err_str: &str) -> Option<u16> {
let marker = "failed (";
let start = err_str.find(marker)? + marker.len();
let rest = &err_str[start..];
let end = rest.find(')')?;
rest[..end].parse().ok()
}
async fn append_pr_footer_if_missing(
gh: &GhHttp,
repo: &str,
pr_number: u64,
task_id: &str,
agent: &str,
model: Option<&str>,
) {
let pr = match gh.get_pr(repo, pr_number).await {
Ok(pr) => pr,
Err(e) => {
tracing::debug!(task_id, error = %e, "failed to fetch PR for footer check");
return;
}
};
let body = pr.body.unwrap_or_default();
if body.contains("via [Orch]") {
return; }
let model_str = model.map(|m| format!(" using `{m}`")).unwrap_or_default();
let task_ref = format_task_ref(task_id);
let footer = format!(
"\n\n---\n*Task {task_ref} · Created by {agent}[bot] via [Orch](https://github.com/gabrielkoerich/orch){model_str}*"
);
let new_body = format!("{body}{footer}");
if let Err(e) = gh.update_pr_body(repo, pr_number, &new_body).await {
tracing::warn!(task_id, error = %e, "failed to append footer to PR (non-fatal)");
} else {
tracing::info!(
task_id,
pr = pr_number,
"appended attribution footer to agent-created PR"
);
}
}
async fn get_current_branch(dir: &Path) -> String {
let output = Command::new("git")
.args(["branch", "--show-current"])
.current_dir(dir)
.output_with_context()
.await;
match output {
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
_ => String::new(),
}
}
pub async fn count_changed_files(dir: &Path) -> anyhow::Result<usize> {
let output = Command::new("git")
.args(["status", "--porcelain"])
.current_dir(dir)
.output_with_context()
.await?;
let status_output = String::from_utf8_lossy(&output.stdout);
let count = status_output
.lines()
.filter(|line| {
let prefix = line.get(0..2).unwrap_or("");
prefix.starts_with('M')
|| prefix.starts_with('A')
|| prefix.starts_with('R')
|| prefix.starts_with('C')
|| prefix.starts_with('?')
})
.count();
Ok(count)
}
async fn check_push_needed(dir: &Path, branch: &str, default_branch: &str) -> (bool, bool) {
let remote_exists = Command::new("git")
.args(["rev-parse", &format!("origin/{branch}")])
.current_dir(dir)
.output_with_context()
.await
.map(|o| o.status.success())
.unwrap_or(false);
let compare_ref = if remote_exists {
format!("origin/{branch}..HEAD")
} else {
format!("origin/{default_branch}..HEAD")
};
tracing::debug!(
branch,
compare_ref,
remote_exists,
"checking unpushed commits"
);
let output = Command::new("git")
.args(["log", &compare_ref, "--oneline"])
.current_dir(dir)
.output_with_context()
.await;
let has_unpushed = match output {
Ok(o) if o.status.success() => {
let out = String::from_utf8_lossy(&o.stdout);
let has = !out.trim().is_empty();
tracing::debug!(branch, has_unpushed = has, "unpushed commits check result");
has
}
_ => {
tracing::debug!(branch, "git log failed for compare ref, assuming unpushed");
true
}
};
(remote_exists, has_unpushed)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pr_create_error_display() {
let err = PrCreateError::ApiError(anyhow::anyhow!("test error"));
assert_eq!(format!("{}", err), "GitHub API error: test error");
}
#[test]
fn pr_create_error_from_anyhow() {
let anyhow_err = anyhow::anyhow!("source error");
let pr_err: PrCreateError = anyhow_err.into();
assert!(matches!(pr_err, PrCreateError::ApiError(_)));
}
#[test]
fn build_git_auth_env_without_token_falls_back_to_ssh_https_conversion() {
let env = build_git_auth_env_for_token(None);
let has_ssh_rule = env
.iter()
.any(|(k, v)| k == "GIT_CONFIG_VALUE_0" && v == "git@github.com:");
assert!(
has_ssh_rule,
"expected SSH insteadOf fallback (value is git@github.com:), got: {env:?}"
);
}
#[test]
fn build_git_auth_env_with_token_covers_both_ssh_and_https() {
let env = build_git_auth_env_for_token(Some("ghp_testtoken1234".into()));
let has_token = env
.iter()
.any(|(_, v)| v.contains("x-access-token:ghp_testtoken1234@github.com"));
assert!(has_token, "expected token in auth URL, got: {env:?}");
let has_https_rule = env
.iter()
.any(|(k, v)| k == "GIT_CONFIG_VALUE_0" && v == "https://github.com/");
assert!(
has_https_rule,
"expected HTTPS insteadOf rule, got: {env:?}"
);
let has_ssh_rule = env
.iter()
.any(|(k, v)| k == "GIT_CONFIG_VALUE_1" && v == "git@github.com:");
assert!(has_ssh_rule, "expected SSH insteadOf rule, got: {env:?}");
}
#[test]
fn find_stash_ref_by_hash_returns_matching_reference() {
let stash_list = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa stash@{1}\nbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb stash@{0}";
assert_eq!(
find_stash_ref_by_hash(stash_list, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
Some("stash@{1}".to_string())
);
assert_eq!(
find_stash_ref_by_hash(stash_list, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"),
Some("stash@{0}".to_string())
);
}
#[test]
fn find_stash_ref_by_hash_returns_none_when_missing_or_malformed() {
let stash_list = "malformed-line\naaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa stash@{2}";
assert_eq!(
find_stash_ref_by_hash(stash_list, "cccccccccccccccccccccccccccccccccccccccc"),
None
);
assert_eq!(find_stash_ref_by_hash("", "anything"), None);
}
#[test]
fn push_needs_rebase_detects_common_rejections() {
assert!(push_needs_rebase(
"! [rejected] branch -> branch (fetch first)"
));
assert!(push_needs_rebase("non-fast-forward update was rejected"));
assert!(push_needs_rebase(
"push rejected because the remote contains work"
));
}
#[test]
fn is_transient_network_error_detects_lfs_locks_timeout() {
let lfs_err = "Post \"https://x-access-token:***@github.com/o/r.git/info/lfs/locks/verify\": dial tcp 140.82.112.4:443: i/o timeout";
assert!(is_transient_network_error(lfs_err));
assert!(is_transient_network_error(
"dial tcp 140.82.112.4:443: connect: connection refused"
));
assert!(is_transient_network_error(
"read tcp 10.0.0.1:51234->140.82.112.4:443: read: connection reset by peer"
));
}
#[test]
fn is_transient_network_error_rejects_ref_rejections() {
assert!(!is_transient_network_error(
"! [rejected] branch -> branch (fetch first)"
));
assert!(!is_transient_network_error(
"remote: error: GH006: Protected branch update failed"
));
}
#[test]
fn sanitize_push_error_strips_url_credentials() {
let raw = "Post \"https://x-access-token:test-secret-1234567890abcdef@github.com/o/r.git/info/lfs/locks/verify\": dial tcp 140.82.112.4:443: i/o timeout";
let sanitized = sanitize_push_error(raw);
assert!(!sanitized.contains("test-secret-1234567890abcdef"));
assert!(sanitized.contains("://***@github.com"));
assert!(sanitized.contains("i/o timeout"));
}
#[test]
fn extract_github_http_status_parses_ghhttp_error_format() {
let err =
"GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (502): Bad Gateway";
assert_eq!(extract_github_http_status(err), Some(502));
let err500 = "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (500): Internal Server Error";
assert_eq!(extract_github_http_status(err500), Some(500));
let err422 = "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (422): Unprocessable Entity";
assert_eq!(extract_github_http_status(err422), Some(422));
let not_github = "some other error";
assert_eq!(extract_github_http_status(not_github), None);
}
#[test]
fn is_transient_github_error_matches_5xx_only() {
let err502 =
"GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (502): Bad Gateway";
assert!(is_transient_github_error(err502));
let err503 = "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (503): Service Unavailable";
assert!(is_transient_github_error(err503));
let err422 = "GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (422): Unprocessable Entity";
assert!(!is_transient_github_error(err422));
let err404 =
"GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (404): Not Found";
assert!(!is_transient_github_error(err404));
}
#[test]
fn is_transient_github_error_matches_connection_errors() {
assert!(is_transient_github_error("connection refused"));
assert!(is_transient_github_error("Connection reset by peer"));
assert!(is_transient_github_error("broken pipe"));
assert!(is_transient_github_error("connection closed unexpectedly"));
assert!(is_transient_github_error(
"transport error: connection lost"
));
assert!(is_transient_github_error(
"fatal: unable to access 'https://github.com/owner/repo.git/': Failed to connect to github.com port 443 after 20990 ms: Couldn't connect to server"
));
assert!(is_transient_github_error(
"Failed to connect to github.com port 443: Connection timed out"
));
assert!(is_transient_github_error(
"Failed to connect to github.com port 443: Connection refused"
));
assert!(is_transient_github_error(
"curl: (7) Failed to connect to github.com port 443: Couldn't connect to server"
));
assert!(is_transient_github_error(
"fatal: unable to access 'https://github.com/owner/repo.git/': Couldn't connect to server"
));
}
#[test]
fn is_transient_github_error_matches_dns_errors() {
assert!(is_transient_github_error("dns error: failed to lookup"));
assert!(is_transient_github_error("failed to resolve hostname"));
assert!(is_transient_github_error("name resolution failed"));
assert!(is_transient_github_error("no such host: api.github.com"));
}
#[test]
fn is_transient_github_error_matches_tls_errors() {
assert!(is_transient_github_error("tls handshake failed"));
assert!(is_transient_github_error("certificate verification failed"));
assert!(is_transient_github_error("ssl error: unknown ca"));
}
#[test]
fn is_transient_github_error_matches_network_unreachable() {
assert!(is_transient_github_error("network is unreachable"));
assert!(is_transient_github_error("host unreachable"));
assert!(is_transient_github_error(
"temporary failure in name resolution"
));
}
#[test]
fn is_transient_github_error_matches_eof_errors() {
assert!(is_transient_github_error("unexpected eof while reading"));
assert!(is_transient_github_error("unexpected end of file"));
}
#[test]
fn is_transient_github_error_matches_rate_limit() {
assert!(is_transient_github_error("rate limit exceeded"));
assert!(is_transient_github_error("429 Too Many Requests"));
assert!(is_transient_github_error("too many requests, retry later"));
}
#[test]
fn is_transient_github_error_matches_timeout() {
assert!(is_transient_github_error("operation timeout"));
assert!(is_transient_github_error("request timeout"));
}
#[test]
fn is_transient_github_error_rejects_non_transient() {
assert!(!is_transient_github_error("permission denied"));
assert!(!is_transient_github_error("file not found"));
assert!(!is_transient_github_error("invalid argument"));
assert!(!is_transient_github_error("authentication failed"));
assert!(!is_transient_github_error("bad credentials"));
}
#[test]
fn is_transient_github_api_error_requires_github_context() {
assert!(is_transient_github_api_error(
"GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (502): Bad Gateway"
));
assert!(is_transient_github_api_error(
"gh error: transport error: connection reset by peer"
));
assert!(is_transient_github_api_error(
"push failed: fatal: unable to access 'https://github.com/owner/repo.git/': Failed to connect to github.com port 443 after 20990 ms: Couldn't connect to server"
));
assert!(!is_transient_github_api_error(
"codex failed: Reconnecting... (stream disconnected before completion: Broken pipe)"
));
assert!(!is_transient_github_api_error(
"agent stream failed: failed to connect to local relay"
));
}
#[test]
fn is_transient_github_api_error_matches_circuit_breaker() {
assert!(is_transient_github_api_error(
"GitHub API transient 5xx circuit-breaker active for 240s"
));
assert!(is_transient_github_api_error(
"create PR failed: GitHub API error: GitHub API transient 5xx circuit-breaker active"
));
assert!(is_transient_github_api_error(
"create PR failed: circuit-breaker"
));
assert!(is_transient_github_api_error(
"create PR failed: circuit breaker tripped"
));
}
#[tokio::test]
async fn auto_commit_unstages_files_on_commit_failure() {
let dir =
std::env::temp_dir().join(format!("orch_test_auto_commit_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let _ = Command::new("git")
.args(["init"])
.current_dir(&dir)
.output()
.await;
let _ = Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(&dir)
.output()
.await;
let _ = Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(&dir)
.output()
.await;
std::fs::write(dir.join("init.txt"), "init").unwrap();
let _ = Command::new("git")
.args(["add", "init.txt"])
.current_dir(&dir)
.output()
.await;
let _ = Command::new("git")
.args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
.current_dir(&dir)
.output()
.await;
std::fs::write(dir.join("new_file.txt"), "content").unwrap();
assert!(has_changes(&dir).await);
let result = auto_commit(&dir, "1", "Test title", "test", 1).await;
assert!(result.unwrap());
assert!(!has_changes(&dir).await);
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn auto_commit_ignores_repo_signing_config() {
let dir =
std::env::temp_dir().join(format!("orch_test_auto_commit_gpg_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
for args in [
vec!["init"],
vec!["config", "user.email", "test@test.com"],
vec!["config", "user.name", "Test"],
vec!["config", "commit.gpgsign", "true"],
vec!["config", "gpg.program", "/usr/bin/false"],
] {
let _ = Command::new("git")
.args(&args)
.current_dir(&dir)
.output()
.await;
}
std::fs::write(dir.join("init.txt"), "init").unwrap();
let _ = Command::new("git")
.args(["add", "init.txt"])
.current_dir(&dir)
.output()
.await;
let _ = Command::new("git")
.args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
.current_dir(&dir)
.output()
.await;
std::fs::write(dir.join("signed_off.txt"), "content").unwrap();
assert!(has_changes(&dir).await);
let result = auto_commit(&dir, "3", "Test title", "test", 1).await;
assert!(result.unwrap());
assert!(!has_changes(&dir).await);
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn auto_commit_unstages_on_pre_commit_hook_failure() {
let dir =
std::env::temp_dir().join(format!("orch_test_auto_commit_hook_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let _ = Command::new("git")
.args(["init"])
.current_dir(&dir)
.output()
.await;
let _ = Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(&dir)
.output()
.await;
let _ = Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(&dir)
.output()
.await;
std::fs::write(dir.join("init.txt"), "init").unwrap();
let _ = Command::new("git")
.args(["add", "init.txt"])
.current_dir(&dir)
.output()
.await;
let _ = Command::new("git")
.args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
.current_dir(&dir)
.output()
.await;
let hooks_dir = dir.join(".git/hooks");
std::fs::create_dir_all(&hooks_dir).unwrap();
std::fs::write(hooks_dir.join("pre-commit"), "#!/bin/sh\nexit 1\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(hooks_dir.join("pre-commit"))
.unwrap()
.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(hooks_dir.join("pre-commit"), perms).unwrap();
}
std::fs::write(dir.join("staged_file.txt"), "content").unwrap();
assert!(has_changes(&dir).await);
let result = auto_commit(&dir, "2", "Test title", "test", 1).await;
assert!(!result.unwrap());
assert!(has_changes(&dir).await);
let cached = Command::new("git")
.args(["diff", "--cached", "--quiet"])
.current_dir(&dir)
.status()
.await;
assert!(
cached.map(|s| s.success()).unwrap_or(false),
"files should be unstaged after commit failure"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn is_workflow_scope_error_detects_backtick_format() {
let err = "! [remote rejected] branch -> branch (refusing to allow an OAuth App to create or update workflow `.github/workflows/ci.yml` without `workflow` scope)";
assert!(is_workflow_scope_error(err));
}
#[test]
fn is_workflow_scope_error_detects_single_quote_format() {
let err = "refusing to allow an OAuth App to create or update workflow '.github/workflows/ci.yml' without 'workflow' scope";
assert!(is_workflow_scope_error(err));
}
#[test]
fn is_workflow_scope_error_detects_refusing_to_allow_variant() {
let err = "remote: refusing to allow a Personal Access Token to create or update workflow `.github/workflows/scan.yml` without `workflow` scope";
assert!(is_workflow_scope_error(err));
}
#[test]
fn is_workflow_scope_error_rejects_unrelated_errors() {
assert!(!is_workflow_scope_error("non-fast-forward"));
assert!(!is_workflow_scope_error("permission denied"));
assert!(!is_workflow_scope_error("authentication failed"));
assert!(!is_workflow_scope_error("rejected"));
}
#[tokio::test]
async fn strip_workflow_files_removes_workflow_from_commits() {
let dir = std::env::temp_dir().join(format!("orch_test_strip_wf_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let _ = Command::new("git")
.args(["init"])
.current_dir(&dir)
.output()
.await;
let _ = Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(&dir)
.output()
.await;
let _ = Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(&dir)
.output()
.await;
std::fs::write(dir.join("init.txt"), "init").unwrap();
let _ = Command::new("git")
.args(["add", "."])
.current_dir(&dir)
.output()
.await;
let _ = Command::new("git")
.args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
.current_dir(&dir)
.output()
.await;
let _ = Command::new("git")
.args(["remote", "add", "origin", dir.to_str().unwrap()])
.current_dir(&dir)
.output()
.await;
let _ = Command::new("git")
.args(["fetch", "origin"])
.current_dir(&dir)
.output()
.await;
let _ = Command::new("git")
.args(["checkout", "-b", "feature"])
.current_dir(&dir)
.output()
.await;
std::fs::write(dir.join("feature.txt"), "feature work").unwrap();
let wf_dir = dir.join(".github/workflows");
std::fs::create_dir_all(&wf_dir).unwrap();
std::fs::write(wf_dir.join("ci.yml"), "name: CI\non: push").unwrap();
let _ = Command::new("git")
.args(["add", "."])
.current_dir(&dir)
.output()
.await;
let _ = Command::new("git")
.args([
"-c",
"commit.gpgsign=false",
"commit",
"-m",
"add feature and workflow",
])
.current_dir(&dir)
.output()
.await;
let branch_out = Command::new("git")
.args(["rev-parse", "--abbrev-ref", "origin/HEAD"])
.current_dir(&dir)
.output()
.await;
let default_branch = branch_out
.ok()
.filter(|o| o.status.success())
.map(|o| {
String::from_utf8_lossy(&o.stdout)
.trim()
.strip_prefix("origin/")
.unwrap_or("main")
.to_string()
})
.unwrap_or_else(|| "main".to_string());
let stripped = strip_workflow_files(&dir, &default_branch).await.unwrap();
assert!(stripped, "should have stripped workflow files");
let ls_output = Command::new("git")
.args(["ls-tree", "-r", "--name-only", "HEAD"])
.current_dir(&dir)
.output()
.await
.unwrap();
let files = String::from_utf8_lossy(&ls_output.stdout);
assert!(
!files.contains(".github/workflows/ci.yml"),
"workflow file should be removed from commits"
);
assert!(
files.contains("feature.txt"),
"non-workflow files should be preserved"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn strip_workflow_files_returns_false_when_no_workflows() {
let dir =
std::env::temp_dir().join(format!("orch_test_strip_wf_none_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let _ = Command::new("git")
.args(["init"])
.current_dir(&dir)
.output()
.await;
let _ = Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(&dir)
.output()
.await;
let _ = Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(&dir)
.output()
.await;
std::fs::write(dir.join("init.txt"), "init").unwrap();
let _ = Command::new("git")
.args(["add", "."])
.current_dir(&dir)
.output()
.await;
let _ = Command::new("git")
.args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
.current_dir(&dir)
.output()
.await;
let _ = Command::new("git")
.args(["remote", "add", "origin", dir.to_str().unwrap()])
.current_dir(&dir)
.output()
.await;
let _ = Command::new("git")
.args(["fetch", "origin"])
.current_dir(&dir)
.output()
.await;
let _ = Command::new("git")
.args(["checkout", "-b", "feature2"])
.current_dir(&dir)
.output()
.await;
std::fs::write(dir.join("feature.txt"), "feature").unwrap();
let _ = Command::new("git")
.args(["add", "."])
.current_dir(&dir)
.output()
.await;
let _ = Command::new("git")
.args([
"-c",
"commit.gpgsign=false",
"commit",
"-m",
"add feature only",
])
.current_dir(&dir)
.output()
.await;
let stripped = strip_workflow_files(&dir, "main").await.unwrap();
assert!(!stripped, "should not strip when no workflow files exist");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn is_invalid_base_error_detects_base_invalid() {
assert!(is_invalid_base_error(
"GitHub API POST https://api.github.com/repos/foo/bar/pulls failed (422): {\"errors\":[{\"resource\":\"PullRequest\",\"field\":\"base\",\"code\":\"invalid\"}]}"
));
assert!(is_invalid_base_error(
"GitHub API POST .../pulls failed (422): Base field is invalid"
));
}
#[test]
fn is_invalid_base_error_rejects_non_base_errors() {
assert!(!is_invalid_base_error(
"GitHub API POST .../pulls failed (422): No commits between main and branch"
));
assert!(!is_invalid_base_error(
"GitHub API POST .../pulls failed (422): head is not a valid ref"
));
assert!(!is_invalid_base_error(
"GitHub API POST .../pulls failed (502): Bad Gateway"
));
assert!(!is_invalid_base_error(
"GitHub API POST .../pulls failed (404): Not Found"
));
}
}