use std::fmt;
pub const DEFAULT_UPSTREAM_REMOTE: &str = "origin";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpstreamIntegrationConfig {
pub remote: String,
pub verify_command: String,
}
impl UpstreamIntegrationConfig {
pub fn new(remote: impl Into<String>, verify_command: impl Into<String>) -> Self {
Self {
remote: remote.into(),
verify_command: verify_command.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UpstreamOptionError {
MissingVerifyCommand,
VerifyCommandWithoutOption,
InvalidRemote(String),
ConflictsWithPush,
DetachedHead,
NotGitRepository,
RemoteNotConfigured(String),
RemoteBranchMissing { remote: String, branch: String },
BaseNotClean(String),
UnrelatedLocalHistory { commit: String, subject: String },
UpstreamRecoveryRequiresOption {
remote: String,
branch: String,
merge_commit: String,
},
PublicationRecoveryRequiresOption {
change_id: String,
remote: String,
branch: String,
integration_commit: String,
},
}
impl fmt::Display for UpstreamOptionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingVerifyCommand => write!(
f,
"--integrate-upstream requires an explicit non-empty --upstream-verify-command"
),
Self::VerifyCommandWithoutOption => write!(
f,
"--upstream-verify-command requires -u/--integrate-upstream"
),
Self::InvalidRemote(value) => write!(
f,
"invalid upstream remote '{}': use a remote name only (no branch selection)",
value
),
Self::ConflictsWithPush => write!(
f,
"-u/--integrate-upstream cannot be combined with --push: --push publishes individual change branches instead of the cumulative base"
),
Self::DetachedHead => write!(
f,
"-u/--integrate-upstream requires an attached HEAD so the same-name remote branch can be resolved"
),
Self::NotGitRepository => write!(
f,
"-u/--integrate-upstream requires a Git repository workspace"
),
Self::RemoteNotConfigured(remote) => {
write!(f, "remote '{}' is not configured in this repository", remote)
}
Self::RemoteBranchMissing { remote, branch } => write!(
f,
"remote '{}' has no branch '{}' matching the cumulative base branch",
remote, branch
),
Self::BaseNotClean(reason) => {
write!(f, "cumulative base is not clean: {}", reason)
}
Self::UnrelatedLocalHistory { commit, subject } => write!(
f,
"local first-parent history contains unrelated commit {} ('{}'); upstream integration would publish it",
commit, subject
),
Self::UpstreamRecoveryRequiresOption {
remote,
branch,
merge_commit,
} => write!(
f,
"unpushed upstream merge {} for {}/{} requires restarting with --integrate-upstream={} and an explicit --upstream-verify-command",
merge_commit, remote, branch, remote
),
Self::PublicationRecoveryRequiresOption {
change_id,
remote,
branch,
integration_commit,
} => write!(
f,
"change '{}' was integrated into cumulative base at {} for required publication to {}/{} and is not proven reachable from that remote branch; it is not merged. Restart with --integrate-upstream={} and a fresh --upstream-verify-command to resume publication",
change_id, integration_commit, remote, branch, remote
),
}
}
}
impl std::error::Error for UpstreamOptionError {}
pub fn parse_upstream_remote(value: &str) -> Result<String, String> {
let trimmed = value.trim();
if trimmed.is_empty() || trimmed.contains(':') || trimmed.contains(char::is_whitespace) {
return Err(UpstreamOptionError::InvalidRemote(value.to_string()).to_string());
}
Ok(trimmed.to_string())
}
pub fn selected_upstream_remote(short_opt_in: bool, long_opt_in: Option<&str>) -> Option<String> {
match long_opt_in {
Some(remote) => Some(remote.to_string()),
None if short_opt_in => Some(DEFAULT_UPSTREAM_REMOTE.to_string()),
None => None,
}
}
pub fn resolve_upstream_config(
integrate_upstream: Option<&str>,
upstream_verify_command: Option<&str>,
) -> Result<Option<UpstreamIntegrationConfig>, UpstreamOptionError> {
let verify_command = upstream_verify_command
.map(str::trim)
.filter(|c| !c.is_empty());
let Some(remote) = integrate_upstream else {
if verify_command.is_some() {
return Err(UpstreamOptionError::VerifyCommandWithoutOption);
}
return Ok(None);
};
let remote = remote.trim();
if remote.is_empty() || remote.contains(':') || remote.contains(char::is_whitespace) {
return Err(UpstreamOptionError::InvalidRemote(remote.to_string()));
}
let Some(verify_command) = verify_command else {
return Err(UpstreamOptionError::MissingVerifyCommand);
};
Ok(Some(UpstreamIntegrationConfig::new(remote, verify_command)))
}
pub fn resolve_frontend_upstream_config(
integrate_upstream: Option<&str>,
upstream_verify_command: Option<&str>,
push_remote: Option<&str>,
) -> Result<Option<UpstreamIntegrationConfig>, UpstreamOptionError> {
let config = resolve_upstream_config(integrate_upstream, upstream_verify_command)?;
let Some(config) = config else {
return Ok(None);
};
if push_remote.is_some() {
return Err(UpstreamOptionError::ConflictsWithPush);
}
Ok(Some(config))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StaticPreconditions {
pub push_remote: Option<String>,
pub is_git_repository: bool,
pub attached_branch: Option<String>,
pub remote_configured: bool,
pub base_dirty_reason: Option<String>,
}
pub fn validate_static_preconditions(
config: &UpstreamIntegrationConfig,
facts: &StaticPreconditions,
) -> Result<String, UpstreamOptionError> {
if facts.push_remote.is_some() {
return Err(UpstreamOptionError::ConflictsWithPush);
}
if !facts.is_git_repository {
return Err(UpstreamOptionError::NotGitRepository);
}
let Some(branch) = facts.attached_branch.as_deref() else {
return Err(UpstreamOptionError::DetachedHead);
};
if !facts.remote_configured {
return Err(UpstreamOptionError::RemoteNotConfigured(
config.remote.clone(),
));
}
if let Some(reason) = &facts.base_dirty_reason {
return Err(UpstreamOptionError::BaseNotClean(reason.clone()));
}
Ok(branch.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn facts() -> StaticPreconditions {
StaticPreconditions {
push_remote: None,
is_git_repository: true,
attached_branch: Some("main".to_string()),
remote_configured: true,
base_dirty_reason: None,
}
}
#[test]
fn upstream_integration_absent_option_is_default_off() {
assert_eq!(resolve_upstream_config(None, None), Ok(None));
}
#[test]
fn upstream_integration_short_and_long_aliases_are_equivalent() {
let short = resolve_upstream_config(Some(DEFAULT_UPSTREAM_REMOTE), Some("cargo test"));
let long = resolve_upstream_config(Some(DEFAULT_UPSTREAM_REMOTE), Some("cargo test"));
assert_eq!(short, long);
assert_eq!(
short.unwrap(),
Some(UpstreamIntegrationConfig::new("origin", "cargo test"))
);
}
#[test]
fn upstream_integration_explicit_remote_is_selected() {
let resolved = resolve_upstream_config(Some("upstream"), Some("cargo test")).unwrap();
assert_eq!(
resolved,
Some(UpstreamIntegrationConfig::new("upstream", "cargo test"))
);
}
#[test]
fn upstream_integration_requires_verify_command() {
assert_eq!(
resolve_upstream_config(Some("origin"), None),
Err(UpstreamOptionError::MissingVerifyCommand)
);
assert_eq!(
resolve_upstream_config(Some("origin"), Some(" ")),
Err(UpstreamOptionError::MissingVerifyCommand)
);
}
#[test]
fn upstream_integration_verify_command_alone_is_rejected() {
assert_eq!(
resolve_upstream_config(None, Some("cargo test")),
Err(UpstreamOptionError::VerifyCommandWithoutOption)
);
}
#[test]
fn upstream_integration_rejects_branch_selection_remote() {
assert!(parse_upstream_remote("origin:main").is_err());
assert!(parse_upstream_remote("").is_err());
assert_eq!(parse_upstream_remote("upstream").unwrap(), "upstream");
assert_eq!(
resolve_upstream_config(Some("origin:main"), Some("cargo test")),
Err(UpstreamOptionError::InvalidRemote(
"origin:main".to_string()
))
);
}
#[test]
fn upstream_integration_rejects_push_combination() {
let config = UpstreamIntegrationConfig::new("origin", "cargo test");
let mut f = facts();
f.push_remote = Some("origin".to_string());
assert_eq!(
validate_static_preconditions(&config, &f),
Err(UpstreamOptionError::ConflictsWithPush)
);
}
#[test]
fn upstream_integration_rejects_detached_head_and_non_git() {
let config = UpstreamIntegrationConfig::new("origin", "cargo test");
let mut detached = facts();
detached.attached_branch = None;
assert_eq!(
validate_static_preconditions(&config, &detached),
Err(UpstreamOptionError::DetachedHead)
);
let mut not_git = facts();
not_git.is_git_repository = false;
assert_eq!(
validate_static_preconditions(&config, ¬_git),
Err(UpstreamOptionError::NotGitRepository)
);
}
#[test]
fn upstream_integration_rejects_missing_remote_and_dirty_base() {
let config = UpstreamIntegrationConfig::new("upstream", "cargo test");
let mut missing = facts();
missing.remote_configured = false;
assert_eq!(
validate_static_preconditions(&config, &missing),
Err(UpstreamOptionError::RemoteNotConfigured("upstream".into()))
);
let mut dirty = facts();
dirty.base_dirty_reason = Some("uncommitted changes".to_string());
assert_eq!(
validate_static_preconditions(&config, &dirty),
Err(UpstreamOptionError::BaseNotClean(
"uncommitted changes".into()
))
);
}
#[test]
fn per_change_upstream_frontends_normalize_to_one_configuration() {
let run = resolve_frontend_upstream_config(
Some(DEFAULT_UPSTREAM_REMOTE),
Some("cargo test"),
None,
);
let bare_tui = resolve_frontend_upstream_config(
Some(DEFAULT_UPSTREAM_REMOTE),
Some("cargo test"),
None,
);
let explicit_tui = resolve_frontend_upstream_config(
Some(DEFAULT_UPSTREAM_REMOTE),
Some("cargo test"),
None,
);
assert_eq!(run, bare_tui);
assert_eq!(bare_tui, explicit_tui);
assert_eq!(
run.unwrap(),
Some(UpstreamIntegrationConfig::new("origin", "cargo test"))
);
}
#[test]
fn per_change_upstream_frontend_default_off_installs_nothing() {
assert_eq!(
resolve_frontend_upstream_config(None, None, Some("origin")),
Ok(None),
"a disabled invocation must not be rejected by upstream-only constraints"
);
}
#[test]
fn per_change_upstream_frontend_rejects_push() {
assert_eq!(
resolve_frontend_upstream_config(Some("origin"), Some("cargo test"), Some("origin")),
Err(UpstreamOptionError::ConflictsWithPush)
);
}
#[test]
fn per_change_upstream_frontend_requires_verify_command_and_valid_remote() {
assert_eq!(
resolve_frontend_upstream_config(Some("origin"), None, None),
Err(UpstreamOptionError::MissingVerifyCommand)
);
assert_eq!(
resolve_frontend_upstream_config(Some("origin"), Some(" "), None),
Err(UpstreamOptionError::MissingVerifyCommand)
);
assert_eq!(
resolve_frontend_upstream_config(Some("origin:main"), Some("cargo test"), None),
Err(UpstreamOptionError::InvalidRemote("origin:main".into()))
);
assert_eq!(
resolve_frontend_upstream_config(None, Some("cargo test"), None),
Err(UpstreamOptionError::VerifyCommandWithoutOption)
);
}
#[test]
fn per_change_upstream_frontend_accepts_explicit_remote() {
assert_eq!(
resolve_frontend_upstream_config(Some("upstream"), Some("cargo test"), None),
Ok(Some(UpstreamIntegrationConfig::new(
"upstream",
"cargo test"
)))
);
}
#[test]
fn upstream_integration_static_validation_returns_base_branch() {
let config = UpstreamIntegrationConfig::new("origin", "cargo test");
assert_eq!(
validate_static_preconditions(&config, &facts()).unwrap(),
"main"
);
}
}