use std::path::{Path, PathBuf};
use anyhow::Result;
use git2::{Oid, Repository};
use serde::Serialize;
use crate::git::remote::RemoteInfo;
use crate::git::resolve_git_binary;
use crate::git::worktree_batch::{
head_branch, is_false, resolve_selection, run_git_in, trimmed_stderr,
};
pub use crate::git::worktree_batch::Selection;
const DEFAULT_REMOTE: &str = "origin";
#[derive(Debug, Clone, Default)]
pub struct PushOptions {
pub git_bin: Option<PathBuf>,
}
impl PushOptions {
fn git_bin(&self) -> PathBuf {
self.git_bin.clone().unwrap_or_else(resolve_git_binary)
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Plan {
pub worktrees: Vec<WorktreeOutcome>,
}
impl Plan {
#[must_use]
pub fn has_pending_pushes(&self) -> bool {
self.worktrees.iter().any(|w| w.result.is_pending())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct WorktreeOutcome {
pub path: PathBuf,
#[serde(skip_serializing_if = "Option::is_none")]
pub branch: Option<String>,
pub remote: String,
pub remote_branch: String,
#[serde(flatten)]
pub result: PushResult,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "status", rename_all = "kebab-case")]
pub enum PushResult {
UpToDate,
WouldFastForward {
ahead: usize,
},
WouldForce {
ahead: usize,
behind: usize,
},
WouldCreate,
Pushed {
forced: bool,
},
Created,
Rejected {
detail: String,
#[serde(skip_serializing_if = "is_false")]
stale: bool,
},
Skipped {
reason: SkipReason,
},
}
impl PushResult {
#[must_use]
pub const fn is_pending(&self) -> bool {
self.pending_kind().is_some()
}
const fn pending_kind(&self) -> Option<PushKind> {
match self {
Self::WouldFastForward { .. } => Some(PushKind::FastForward),
Self::WouldForce { .. } => Some(PushKind::Force),
Self::WouldCreate => Some(PushKind::Create),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum SkipReason {
DetachedHead,
NotAWorktree,
NoRemote,
DefaultBranchForcePush,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PushKind {
FastForward,
Force,
Create,
}
pub fn plan(selection: &Selection) -> Result<Plan> {
let paths = resolve_selection(selection)?;
let worktrees = paths.iter().map(|path| classify(path)).collect();
Ok(Plan { worktrees })
}
#[must_use]
pub fn execute(plan: Plan, opts: &PushOptions) -> Vec<WorktreeOutcome> {
let git = opts.git_bin();
plan.worktrees
.into_iter()
.map(|mut outcome| {
if let Some(kind) = outcome.result.pending_kind() {
outcome.result = push_worktree(&git, &outcome, kind);
}
outcome
})
.collect()
}
fn classify(path: &Path) -> WorktreeOutcome {
let canon = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
let Ok(repo) = Repository::discover(&canon) else {
return WorktreeOutcome::skipped(canon, None, SkipReason::NotAWorktree);
};
let (branch, head_oid) = head_branch(&repo);
let (Some(branch), Some(head)) = (branch, head_oid) else {
return WorktreeOutcome::skipped(canon, None, SkipReason::DetachedHead);
};
let Some(target) = resolve_target(&repo, &branch) else {
return WorktreeOutcome::skipped(canon, Some(branch), SkipReason::NoRemote);
};
let outcome = |result| WorktreeOutcome {
path: canon.clone(),
branch: Some(branch.clone()),
remote: target.remote.clone(),
remote_branch: target.remote_branch.clone(),
result,
};
let Some(upstream_oid) = target.upstream_oid else {
return outcome(PushResult::WouldCreate);
};
let Some((ahead, behind)) = repo.graph_ahead_behind(head, upstream_oid).ok() else {
return outcome(PushResult::UpToDate);
};
match (ahead, behind) {
(0, _) => outcome(PushResult::UpToDate),
(ahead, 0) => outcome(PushResult::WouldFastForward { ahead }),
(ahead, behind) => {
if is_default_branch(&repo, &target) {
return outcome(PushResult::Skipped {
reason: SkipReason::DefaultBranchForcePush,
});
}
outcome(PushResult::WouldForce { ahead, behind })
}
}
}
struct Target {
remote: String,
remote_branch: String,
upstream_oid: Option<Oid>,
}
fn resolve_target(repo: &Repository, branch: &str) -> Option<Target> {
let refname = format!("refs/heads/{branch}");
if let Some(remote) = buf_string(repo.branch_upstream_remote(&refname)) {
let upstream_oid = buf_string(repo.branch_upstream_name(&refname))
.and_then(|name| repo.refname_to_id(&name).ok());
return Some(Target {
remote_branch: merge_ref_branch(repo, branch).unwrap_or_else(|| branch.to_string()),
remote,
upstream_oid,
});
}
Some(Target {
remote: fallback_remote(repo)?,
remote_branch: branch.to_string(),
upstream_oid: None,
})
}
fn buf_string(buf: std::result::Result<git2::Buf, git2::Error>) -> Option<String> {
buf.ok()?.as_str().ok().map(ToString::to_string)
}
fn merge_ref_branch(repo: &Repository, branch: &str) -> Option<String> {
let merge = repo
.config()
.ok()?
.get_string(&format!("branch.{branch}.merge"))
.ok()?;
merge.strip_prefix("refs/heads/").map(ToString::to_string)
}
fn fallback_remote(repo: &Repository) -> Option<String> {
let remotes = repo.remotes().ok()?;
let names: Vec<String> = remotes
.iter()
.flatten()
.flatten()
.map(String::from)
.collect();
if names.iter().any(|name| name == DEFAULT_REMOTE) {
return Some(DEFAULT_REMOTE.to_string());
}
match names.as_slice() {
[only] => Some(only.clone()),
_ => None,
}
}
fn is_default_branch(repo: &Repository, target: &Target) -> bool {
RemoteInfo::detect_main_branch_local(repo, &target.remote)
.is_some_and(|default| default == target.remote_branch)
}
impl WorktreeOutcome {
fn skipped(path: PathBuf, branch: Option<String>, reason: SkipReason) -> Self {
Self {
path,
branch,
remote: String::new(),
remote_branch: String::new(),
result: PushResult::Skipped { reason },
}
}
}
fn push_worktree(git: &Path, outcome: &WorktreeOutcome, kind: PushKind) -> PushResult {
let branch = outcome.branch.as_deref().unwrap_or_default();
let args = push_args(kind, &outcome.remote, branch, &outcome.remote_branch);
let argv: Vec<&str> = args.iter().map(String::as_str).collect();
let output = match run_git_in(git, &outcome.path, &argv) {
Ok(output) => output,
Err(err) => {
return PushResult::Rejected {
detail: format!("{err:#}"),
stale: false,
};
}
};
let stdout = String::from_utf8_lossy(&output.stdout);
if let Some(result) = parse_porcelain(&stdout).into_iter().find_map(result_of) {
return result;
}
if output.status.success() {
return match kind {
PushKind::Create => PushResult::Created,
PushKind::Force => PushResult::Pushed { forced: true },
PushKind::FastForward => PushResult::Pushed { forced: false },
};
}
PushResult::Rejected {
detail: trimmed_stderr(&output),
stale: false,
}
}
fn push_args(kind: PushKind, remote: &str, local: &str, remote_branch: &str) -> Vec<String> {
let mut args = vec!["push".to_string(), "--porcelain".to_string()];
match kind {
PushKind::FastForward => {}
PushKind::Force => {
args.push("--force-with-lease".to_string());
args.push("--force-if-includes".to_string());
}
PushKind::Create => args.push("--set-upstream".to_string()),
}
args.push(remote.to_string());
args.push(format!("refs/heads/{local}:refs/heads/{remote_branch}"));
args
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PorcelainLine {
flag: char,
summary: String,
reason: Option<String>,
}
fn parse_porcelain(stdout: &str) -> Vec<PorcelainLine> {
stdout.lines().filter_map(parse_porcelain_line).collect()
}
fn parse_porcelain_line(line: &str) -> Option<PorcelainLine> {
let mut fields = line.split('\t');
let flag_field = fields.next()?;
let _refs = fields.next()?;
let rest = fields.next()?;
let mut chars = flag_field.chars();
let flag = chars.next()?;
if chars.next().is_some() {
return None;
}
let (summary, reason) = split_reason(rest);
Some(PorcelainLine {
flag,
summary,
reason,
})
}
fn split_reason(rest: &str) -> (String, Option<String>) {
let rest = rest.trim();
if let Some(stripped) = rest.strip_suffix(')') {
if let Some(open) = stripped.rfind('(') {
return (
stripped[..open].trim().to_string(),
Some(stripped[open + 1..].trim().to_string()),
);
}
}
(rest.to_string(), None)
}
fn result_of(line: PorcelainLine) -> Option<PushResult> {
match line.flag {
'=' => Some(PushResult::UpToDate),
' ' => Some(PushResult::Pushed { forced: false }),
'+' => Some(PushResult::Pushed { forced: true }),
'*' => Some(PushResult::Created),
'!' => {
let reason = line.reason.unwrap_or(line.summary);
Some(PushResult::Rejected {
stale: is_lease_refusal(&reason),
detail: reason,
})
}
_ => None,
}
}
fn is_lease_refusal(reason: &str) -> bool {
let reason = reason.to_ascii_lowercase();
reason.contains("stale info") || reason.contains("remote ref updated since checkout")
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::git::worktree_batch::test_serial_lock;
fn serial() -> std::sync::MutexGuard<'static, ()> {
test_serial_lock()
}
#[test]
fn a_fast_forward_push_uses_no_force_flag_at_all() {
assert_eq!(
push_args(PushKind::FastForward, "origin", "feat", "feat"),
vec![
"push",
"--porcelain",
"origin",
"refs/heads/feat:refs/heads/feat"
],
);
}
#[test]
fn a_forced_push_pairs_the_lease_with_force_if_includes() {
let args = push_args(PushKind::Force, "origin", "feat", "feat");
assert_eq!(
args,
vec![
"push",
"--porcelain",
"--force-with-lease",
"--force-if-includes",
"origin",
"refs/heads/feat:refs/heads/feat"
],
);
assert!(
args.iter().all(|a| !a.starts_with("--force-with-lease=")),
"the lease must stay valueless or --force-if-includes is a no-op"
);
}
#[test]
fn no_push_flavour_ever_emits_bare_force() {
for kind in [PushKind::FastForward, PushKind::Force, PushKind::Create] {
let args = push_args(kind, "origin", "feat", "feat");
assert!(
!args.iter().any(|a| a == "--force" || a == "-f"),
"{kind:?} must never force without a lease"
);
}
}
#[test]
fn creating_a_branch_sets_its_upstream() {
assert_eq!(
push_args(PushKind::Create, "upstream", "feat", "feat"),
vec![
"push",
"--porcelain",
"--set-upstream",
"upstream",
"refs/heads/feat:refs/heads/feat"
],
);
}
#[test]
fn the_refspec_honours_a_differing_remote_branch_name() {
let args = push_args(PushKind::Force, "origin", "local-name", "remote-name");
assert_eq!(
args.last().unwrap(),
"refs/heads/local-name:refs/heads/remote-name",
"a `branch.<n>.merge` pointing elsewhere must not be pushed over the local name"
);
}
#[test]
fn porcelain_parsing_maps_every_documented_flag() {
let cases = [
(
"=\trefs/heads/a:refs/heads/a\t[up to date]",
PushResult::UpToDate,
),
(
" \trefs/heads/a:refs/heads/a\tabc123..def456",
PushResult::Pushed { forced: false },
),
(
"+\trefs/heads/a:refs/heads/a\tabc123...def456 (forced update)",
PushResult::Pushed { forced: true },
),
(
"*\trefs/heads/a:refs/heads/a\t[new branch]",
PushResult::Created,
),
];
for (line, expected) in cases {
let parsed = parse_porcelain(line);
assert_eq!(parsed.len(), 1, "failed to parse {line:?}");
assert_eq!(result_of(parsed[0].clone()), Some(expected), "for {line:?}");
}
}
#[test]
fn porcelain_parsing_skips_the_header_and_trailer() {
let stdout = "To git@github.com:o/r.git\n\
=\trefs/heads/a:refs/heads/a\t[up to date]\n\
Done\n";
let parsed = parse_porcelain(stdout);
assert_eq!(parsed.len(), 1, "only the status line is a status line");
assert_eq!(parsed[0].flag, '=');
}
#[test]
fn a_stale_lease_rejection_is_distinguished_from_an_ordinary_one() {
let stale = parse_porcelain("!\trefs/heads/a:refs/heads/a\t[rejected] (stale info)");
assert_eq!(
result_of(stale[0].clone()),
Some(PushResult::Rejected {
detail: "stale info".to_string(),
stale: true,
}),
);
let includes = parse_porcelain(
"!\trefs/heads/a:refs/heads/a\t[rejected] (remote ref updated since checkout)",
);
assert!(
matches!(result_of(includes[0].clone()), Some(PushResult::Rejected { stale, .. }) if stale),
"--force-if-includes wording is a lease refusal too"
);
let hook = parse_porcelain(
"!\trefs/heads/a:refs/heads/a\t[remote rejected] (pre-receive hook declined)",
);
assert!(
matches!(result_of(hook[0].clone()), Some(PushResult::Rejected { stale, .. }) if !stale),
"a server-side hook refusal is not a lease refusal"
);
}
#[test]
fn a_rejection_without_a_reason_falls_back_to_the_summary() {
let parsed = parse_porcelain("!\trefs/heads/a:refs/heads/a\t[rejected]");
assert_eq!(
result_of(parsed[0].clone()),
Some(PushResult::Rejected {
detail: "[rejected]".to_string(),
stale: false,
}),
);
}
#[test]
fn split_reason_leaves_a_parenthesis_free_summary_alone() {
assert_eq!(
split_reason("abc123..def456"),
("abc123..def456".to_string(), None)
);
assert_eq!(
split_reason("[rejected] (non-fast-forward)"),
(
"[rejected]".to_string(),
Some("non-fast-forward".to_string())
)
);
assert_eq!(
split_reason("weird)"),
("weird)".to_string(), None),
"a trailing `)` with no opening `(` is part of the summary, not a reason"
);
}
#[test]
fn a_multi_character_first_field_is_not_a_status_line() {
assert!(parse_porcelain("To\tgit@host:o/r.git\tsomething").is_empty());
assert!(parse_porcelain("\trefs/heads/a:refs/heads/a\tsummary").is_empty());
}
#[test]
fn an_unknown_status_flag_yields_no_result() {
let parsed = parse_porcelain("-\t:refs/heads/a\t[deleted]");
assert_eq!(parsed.len(), 1, "the line still parses structurally");
assert_eq!(result_of(parsed[0].clone()), None);
}
#[test]
fn an_unpushed_branch_would_be_created() {
let _guard = serial();
let scenario = Scenario::new();
let wt = scenario.add_worktree("feature-a");
let outcome = classify(&wt);
assert_eq!(outcome.result, PushResult::WouldCreate);
assert_eq!(
outcome.remote, "origin",
"an upstream-less branch falls back to origin"
);
assert_eq!(outcome.remote_branch, "feature-a");
}
#[test]
fn a_branch_ahead_of_its_upstream_would_fast_forward() {
let _guard = serial();
let scenario = Scenario::new();
let wt = scenario.add_worktree("feature-a");
scenario.publish(&wt, "feature-a");
scenario.commit_in(&wt, "file.txt", "local\n", "local work");
assert_eq!(
classify(&wt).result,
PushResult::WouldFastForward { ahead: 1 },
"ahead-only needs no lease"
);
}
#[test]
fn a_published_branch_with_nothing_new_is_up_to_date() {
let _guard = serial();
let scenario = Scenario::new();
let wt = scenario.add_worktree("feature-a");
scenario.publish(&wt, "feature-a");
assert_eq!(classify(&wt).result, PushResult::UpToDate);
}
#[test]
fn a_rewritten_branch_would_force() {
let _guard = serial();
let scenario = Scenario::new();
let wt = scenario.add_worktree("feature-a");
scenario.commit_in(&wt, "file.txt", "worktree first\n", "first");
scenario.publish(&wt, "feature-a");
scenario.git_in(&wt, &["commit", "--amend", "-m", "rewritten"]);
assert!(
matches!(
classify(&wt).result,
PushResult::WouldForce {
ahead: 1,
behind: 1
}
),
"a rewritten tip diverges and needs the lease"
);
}
#[test]
fn a_dangling_upstream_ref_is_not_guessed_at_as_a_force() {
let _guard = serial();
let scenario = Scenario::new();
let wt = scenario.add_worktree("feature-a");
scenario.publish(&wt, "feature-a");
scenario.commit_in(&wt, "file.txt", "local\n", "local work");
assert_eq!(
classify(&wt).result,
PushResult::WouldFastForward { ahead: 1 },
"precondition: an intact upstream classifies normally"
);
let tracking = scenario.local.join(".git/refs/remotes/origin/feature-a");
std::fs::create_dir_all(tracking.parent().unwrap()).unwrap();
std::fs::write(&tracking, "0123456789abcdef0123456789abcdef01234567\n").unwrap();
assert_eq!(classify(&wt).result, PushResult::UpToDate);
}
#[test]
fn a_detached_head_is_skipped() {
let _guard = serial();
let scenario = Scenario::new();
let wt = scenario.add_worktree("feature-a");
scenario.git_in(&wt, &["checkout", "--detach"]);
assert_eq!(
classify(&wt).result,
PushResult::Skipped {
reason: SkipReason::DetachedHead
},
);
}
#[test]
fn a_non_worktree_path_is_skipped_rather_than_failing_the_batch() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(
classify(dir.path()).result,
PushResult::Skipped {
reason: SkipReason::NotAWorktree
},
);
}
#[test]
fn a_dirty_worktree_is_not_a_skip() {
let _guard = serial();
let scenario = Scenario::new();
let wt = scenario.add_worktree("feature-a");
scenario.publish(&wt, "feature-a");
scenario.commit_in(&wt, "file.txt", "committed\n", "work");
std::fs::write(wt.join("keep.txt"), "uncommitted\n").unwrap();
assert_eq!(
classify(&wt).result,
PushResult::WouldFastForward { ahead: 1 },
"a dirty tree must not suppress a push"
);
}
#[test]
fn force_pushing_the_remote_default_branch_is_refused() {
let _guard = serial();
let scenario = Scenario::new();
scenario.git_in(&scenario.local, &["commit", "--amend", "-m", "rewritten"]);
let outcome = classify(&scenario.local);
assert_eq!(
outcome.result,
PushResult::Skipped {
reason: SkipReason::DefaultBranchForcePush
},
"a force-push to the default branch publishes a rewrite to everyone",
);
}
#[test]
fn fast_forwarding_the_remote_default_branch_stays_allowed() {
let _guard = serial();
let scenario = Scenario::new();
scenario.commit_in(&scenario.local, "file.txt", "more\n", "more");
assert_eq!(
classify(&scenario.local).result,
PushResult::WouldFastForward { ahead: 1 },
"an ordinary push to the default branch destroys nothing",
);
}
#[test]
fn a_rewritten_branch_is_force_pushed_with_the_lease() {
let _guard = serial();
let scenario = Scenario::new();
let wt = scenario.add_worktree("feature-a");
scenario.commit_in(&wt, "file.txt", "worktree first\n", "first");
scenario.publish(&wt, "feature-a");
scenario.git_in(&wt, &["commit", "--amend", "-m", "rewritten"]);
let rewritten = scenario.head_oid(&wt);
let plan = plan(&Selection::Paths(vec![wt])).unwrap();
assert!(plan.has_pending_pushes());
let outcomes = execute(plan, &PushOptions::default());
assert_eq!(outcomes.len(), 1);
assert_eq!(
outcomes[0].result,
PushResult::Pushed { forced: true },
"unexpected outcome: {:?}",
outcomes[0],
);
assert_eq!(
scenario.origin_oid("refs/heads/feature-a"),
Some(rewritten),
"the remote must carry the rewritten tip",
);
}
#[test]
fn an_unpublished_branch_is_created_with_its_upstream() {
let _guard = serial();
let scenario = Scenario::new();
let wt = scenario.add_worktree("feature-a");
scenario.commit_in(&wt, "file.txt", "new\n", "new work");
let tip = scenario.head_oid(&wt);
let plan = plan(&Selection::Paths(vec![wt.clone()])).unwrap();
let outcomes = execute(plan, &PushOptions::default());
assert_eq!(outcomes[0].result, PushResult::Created);
assert_eq!(scenario.origin_oid("refs/heads/feature-a"), Some(tip));
assert_eq!(classify(&wt).result, PushResult::UpToDate);
}
#[test]
fn a_lease_refusal_is_reported_rather_than_forced_through() {
let _guard = serial();
let scenario = Scenario::new();
let wt = scenario.add_worktree("feature-a");
scenario.commit_in(&wt, "file.txt", "worktree first\n", "first");
scenario.publish(&wt, "feature-a");
let before = scenario.origin_oid("refs/heads/feature-a");
scenario.advance_origin("refs/heads/feature-a", "theirs\n");
let theirs = scenario.origin_oid("refs/heads/feature-a");
assert_ne!(before, theirs);
scenario.git_in(&wt, &["commit", "--amend", "-m", "rewritten"]);
let plan = plan(&Selection::Paths(vec![wt])).unwrap();
let outcomes = execute(plan, &PushOptions::default());
match &outcomes[0].result {
PushResult::Rejected { stale, detail } => assert!(
*stale,
"a lease refusal must be flagged so the report can name `git fetch`; got {detail:?}"
),
other => panic!("expected the lease to refuse, got {other:?}"),
}
assert_eq!(
scenario.origin_oid("refs/heads/feature-a"),
theirs,
"their commit must survive",
);
}
#[test]
fn a_batch_continues_past_a_skipped_worktree() {
let _guard = serial();
let scenario = Scenario::new();
let pushable = scenario.add_worktree("feature-a");
scenario.commit_in(&pushable, "file.txt", "new\n", "new work");
let detached = scenario.add_worktree("feature-b");
scenario.git_in(&detached, &["checkout", "--detach"]);
let plan = plan(&Selection::Paths(vec![detached, pushable])).unwrap();
let outcomes = execute(plan, &PushOptions::default());
assert_eq!(outcomes.len(), 2, "selection order is preserved");
assert_eq!(
outcomes[0].result,
PushResult::Skipped {
reason: SkipReason::DetachedHead
},
);
assert_eq!(outcomes[1].result, PushResult::Created);
}
#[test]
fn all_selects_every_worktree_of_the_repository() {
let _guard = serial();
let scenario = Scenario::new();
scenario.add_worktree("feature-a");
scenario.add_worktree("feature-b");
let plan = plan(&Selection::All {
base: scenario.local,
})
.unwrap();
assert_eq!(
plan.worktrees.len(),
3,
"the main working tree is a target like any other (ADR-0060)"
);
}
#[test]
fn a_repository_with_no_remote_has_nowhere_to_publish() {
let _guard = serial();
let dir = tempfile::tempdir().unwrap();
let local = dir.path().join("solo");
std::fs::create_dir_all(&local).unwrap();
git_at(&local, &["init", "-b", "main"]);
config_repo(&local, "Test", "test@example.com");
std::fs::write(local.join("file.txt"), "x\n").unwrap();
git_at(&local, &["add", "file.txt"]);
git_at(&local, &["commit", "-m", "first"]);
assert_eq!(
classify(&local).result,
PushResult::Skipped {
reason: SkipReason::NoRemote
},
);
}
#[test]
fn a_sole_non_origin_remote_is_the_fallback_destination() {
let _guard = serial();
let dir = tempfile::tempdir().unwrap();
let local = dir.path().join("solo");
std::fs::create_dir_all(&local).unwrap();
git_at(&local, &["init", "-b", "main"]);
config_repo(&local, "Test", "test@example.com");
std::fs::write(local.join("file.txt"), "x\n").unwrap();
git_at(&local, &["add", "file.txt"]);
git_at(&local, &["commit", "-m", "first"]);
git_at(&local, &["remote", "add", "upstream", "/nonexistent.git"]);
let outcome = classify(&local);
assert_eq!(outcome.result, PushResult::WouldCreate);
assert_eq!(outcome.remote, "upstream");
}
#[test]
fn several_remotes_without_origin_are_too_ambiguous_to_guess() {
let _guard = serial();
let dir = tempfile::tempdir().unwrap();
let local = dir.path().join("solo");
std::fs::create_dir_all(&local).unwrap();
git_at(&local, &["init", "-b", "main"]);
config_repo(&local, "Test", "test@example.com");
std::fs::write(local.join("file.txt"), "x\n").unwrap();
git_at(&local, &["add", "file.txt"]);
git_at(&local, &["commit", "-m", "first"]);
git_at(&local, &["remote", "add", "upstream", "/a.git"]);
git_at(&local, &["remote", "add", "fork", "/b.git"]);
assert_eq!(
classify(&local).result,
PushResult::Skipped {
reason: SkipReason::NoRemote
},
"publishing to an arbitrary one of several remotes would be a guess",
);
}
fn outcome_at(path: &Path, result: PushResult) -> WorktreeOutcome {
WorktreeOutcome {
path: path.to_path_buf(),
branch: Some("feat".to_string()),
remote: "origin".to_string(),
remote_branch: "feat".to_string(),
result,
}
}
fn execute_against_stub(script: &str, result: PushResult) -> PushResult {
use crate::test_support::shim::{retry_on_etxtbsy, shim_lock, write_exec_script};
let _guard = shim_lock();
let dir = tempfile::tempdir().unwrap();
let stub = dir.path().join("git-stub");
write_exec_script(&stub, script);
let opts = PushOptions {
git_bin: Some(stub),
};
let plan = Plan {
worktrees: vec![outcome_at(dir.path(), result)],
};
retry_on_etxtbsy(|| {
let outcomes = execute(plan.clone(), &opts);
let result = outcomes.into_iter().next().unwrap().result;
match &result {
PushResult::Rejected { detail, .. } if detail.contains("Text file busy") => {
Err(anyhow::anyhow!(std::io::Error::from_raw_os_error(26)))
}
_ => Ok(result),
}
})
.unwrap()
}
#[test]
fn a_git_that_cannot_be_spawned_is_reported_not_panicked() {
let dir = tempfile::tempdir().unwrap();
let opts = PushOptions {
git_bin: Some(dir.path().join("no-such-git")),
};
let plan = Plan {
worktrees: vec![outcome_at(dir.path(), PushResult::WouldCreate)],
};
let outcomes = execute(plan, &opts);
assert!(
matches!(
&outcomes[0].result,
PushResult::Rejected { stale: false, detail } if detail.contains("failed to execute")
),
"unexpected outcome: {:?}",
outcomes[0].result,
);
}
#[test]
fn a_silent_successful_git_is_trusted_by_its_exit_status() {
let cases = [
(PushResult::WouldCreate, PushResult::Created),
(
PushResult::WouldForce {
ahead: 1,
behind: 1,
},
PushResult::Pushed { forced: true },
),
(
PushResult::WouldFastForward { ahead: 1 },
PushResult::Pushed { forced: false },
),
];
for (planned, expected) in cases {
assert_eq!(
execute_against_stub("#!/bin/sh\nexit 0\n", planned.clone()),
expected,
"for a planned {planned:?}",
);
}
}
#[test]
fn a_silent_failing_git_is_reported_with_its_stderr() {
let result = execute_against_stub(
"#!/bin/sh\necho 'fatal: could not read from remote' >&2\nexit 128\n",
PushResult::WouldFastForward { ahead: 1 },
);
assert_eq!(
result,
PushResult::Rejected {
detail: "fatal: could not read from remote".to_string(),
stale: false,
},
);
}
#[test]
fn a_porcelain_status_line_wins_over_the_exit_status() {
let result = execute_against_stub(
"#!/bin/sh\n\
echo 'To /origin.git'\n\
printf '!\\trefs/heads/feat:refs/heads/feat\\t[rejected] (stale info)\\n'\n\
echo 'Done'\n\
exit 1\n",
PushResult::WouldForce {
ahead: 1,
behind: 1,
},
);
assert_eq!(
result,
PushResult::Rejected {
detail: "stale info".to_string(),
stale: true,
},
);
}
struct Scenario {
root: tempfile::TempDir,
origin: PathBuf,
local: PathBuf,
}
impl Scenario {
fn new() -> Self {
let root = tempfile::tempdir().unwrap();
let origin = root.path().join("origin.git");
let local = root.path().join("local");
std::fs::create_dir_all(&origin).unwrap();
std::fs::create_dir_all(&local).unwrap();
git_at(&origin, &["init", "--bare", "-b", "main"]);
git_at(&local, &["init", "-b", "main"]);
config_repo(&local, "Test", "test@example.com");
std::fs::write(local.join("file.txt"), "first\n").unwrap();
std::fs::write(local.join("keep.txt"), "keep\n").unwrap();
git_at(&local, &["add", "file.txt", "keep.txt"]);
git_at(&local, &["commit", "-m", "first"]);
git_at(
&local,
&["remote", "add", "origin", origin.to_str().unwrap()],
);
git_at(&local, &["push", "-u", "origin", "main"]);
Self {
root,
origin,
local,
}
}
fn add_worktree(&self, name: &str) -> PathBuf {
let path = self.root.path().join(name);
git_at(
&self.local,
&[
"worktree",
"add",
"-b",
name,
path.to_str().unwrap(),
"main",
],
);
std::fs::canonicalize(&path).unwrap()
}
fn publish(&self, wt: &Path, branch: &str) {
git_at(wt, &["push", "-u", "origin", branch]);
}
fn commit_in(&self, wt: &Path, file: &str, content: &str, msg: &str) {
std::fs::write(wt.join(file), content).unwrap();
git_at(wt, &["add", file]);
git_at(wt, &["commit", "-m", msg]);
}
fn git_in(&self, wt: &Path, args: &[&str]) {
git_at(wt, args);
}
fn advance_origin(&self, refname: &str, content: &str) {
let repo = Repository::open_bare(&self.origin).unwrap();
let parent = repo
.find_commit(repo.refname_to_id(refname).unwrap())
.unwrap();
let mut builder = repo.treebuilder(Some(&parent.tree().unwrap())).unwrap();
let blob = repo.blob(content.as_bytes()).unwrap();
builder.insert("file.txt", blob, 0o100_644).unwrap();
let tree = repo.find_tree(builder.write().unwrap()).unwrap();
let sig = git2::Signature::now("Other", "other@example.com").unwrap();
repo.commit(Some(refname), &sig, &sig, "theirs", &tree, &[&parent])
.unwrap();
}
fn origin_oid(&self, refname: &str) -> Option<Oid> {
Repository::open_bare(&self.origin)
.unwrap()
.refname_to_id(refname)
.ok()
}
fn head_oid(&self, wt: &Path) -> Oid {
Repository::open(wt)
.unwrap()
.head()
.unwrap()
.target()
.unwrap()
}
}
fn config_repo(dir: &Path, name: &str, email: &str) {
git_at(dir, &["config", "user.name", name]);
git_at(dir, &["config", "user.email", email]);
git_at(dir, &["config", "commit.gpgsign", "false"]);
}
fn git_at(dir: &Path, args: &[&str]) {
let output = run_git_in(&resolve_git_binary(), dir, args).unwrap();
assert!(
output.status.success(),
"git {args:?} in {} failed: {}",
dir.display(),
String::from_utf8_lossy(&output.stderr)
);
}
}