use std::collections::HashMap;
#[cfg(feature = "http")]
use std::io::Read;
use std::path::{Path, PathBuf};
use sley_config::GitConfig;
use sley_core::{GitError, ObjectFormat, ObjectId, Result};
use sley_object::{Commit, ObjectType};
use sley_odb::{FileObjectDatabase, ObjectReader, collect_reachable_object_ids};
#[cfg(feature = "http")]
use sley_protocol::{
GitService, ReceivePackFeatures, ReceivePackPushRequestOptions, parse_receive_pack_features,
read_receive_pack_report_status, smart_http_rpc_request_content_type,
smart_http_rpc_result_content_type,
};
use sley_protocol::{
PushSourceRef, ReceivePackCommand, ReceivePackCommandStatus, ReceivePackPushRequest,
ReceivePackReportStatus, ReceivePackRequest, ReceivePackUnpackStatus, RefAdvertisement,
RefSpec, parse_refspec, plan_push_commands,
};
use crate::pack::push_pack_roots;
#[cfg(feature = "http")]
use crate::pack::{PushPackRequest, build_receive_pack_body};
use sley_refs::{FileRefStore, Ref, RefTarget};
use sley_transport::RemoteUrl;
#[cfg(feature = "http")]
use sley_transport::{HttpClient, http_smart_rpc_url};
use crate::{CredentialProvider, ProgressSink};
pub enum PushDestination {
Http(RemoteUrl),
Ssh(RemoteUrl),
Git(RemoteUrl),
Local {
git_dir: PathBuf,
common_git_dir: PathBuf,
},
}
#[derive(Debug, Clone, Copy, Default)]
pub struct PushOptions {
pub quiet: bool,
pub force: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PushCommand {
pub src: Option<ObjectId>,
pub dst: String,
pub expected_old: Option<ObjectId>,
pub force: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PushAction {
Create {
dst: String,
new: ObjectId,
},
Update {
dst: String,
old: ObjectId,
new: ObjectId,
},
Delete {
dst: String,
old: Option<ObjectId>,
},
}
impl From<PushAction> for PushCommand {
fn from(value: PushAction) -> Self {
match value {
PushAction::Create { dst, new } => Self {
src: Some(new),
dst,
expected_old: None,
force: false,
},
PushAction::Update { dst, old, new } => Self {
src: Some(new),
dst,
expected_old: Some(old),
force: false,
},
PushAction::Delete { dst, old } => Self {
src: None,
dst,
expected_old: old,
force: false,
},
}
}
}
#[derive(Debug, Clone)]
pub struct PushActionPlan {
pub commands: Vec<PushCommand>,
pub pack_objects: Vec<ObjectId>,
pub options: PushOptions,
}
impl PushActionPlan {
pub fn from_actions(actions: Vec<PushAction>, options: PushOptions) -> Self {
Self {
commands: actions.into_iter().map(PushCommand::from).collect(),
pack_objects: Vec::new(),
options,
}
}
pub fn from_commands(commands: Vec<PushCommand>, options: PushOptions) -> Self {
Self {
commands,
pack_objects: Vec::new(),
options,
}
}
pub fn from_commands_and_infer_pack_roots(
commands: Vec<PushCommand>,
options: PushOptions,
) -> Self {
let mut pack_objects = Vec::new();
for command in &commands {
let Some(src) = command.src.as_ref() else {
continue;
};
if !pack_objects.contains(src) {
pack_objects.push(*src);
}
}
Self {
commands,
pack_objects,
options,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct PushOutcome {
pub commands: Vec<ReceivePackCommand>,
pub report: Option<ReceivePackReportStatus>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PushRefStatus {
Ok,
UpToDate,
RejectNonFastForward,
RejectStale,
RejectRemoteUpdated,
RejectAlreadyExists,
RemoteReject(String),
AtomicPushFailed,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PushReportRef {
pub src: Option<String>,
pub dst: String,
pub old_id: ObjectId,
pub new_id: ObjectId,
pub forced: bool,
pub status: PushRefStatus,
}
impl PushReportRef {
pub fn is_deletion(&self) -> bool {
self.new_id.is_null()
}
pub fn had_error(&self) -> bool {
!matches!(self.status, PushRefStatus::Ok | PushRefStatus::UpToDate)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PushStatusReport {
pub refs: Vec<PushReportRef>,
}
impl PushStatusReport {
pub fn had_errors(&self) -> bool {
self.refs.iter().any(PushReportRef::had_error)
}
pub fn refs_pushed(&self) -> bool {
self.refs.iter().any(|reference| {
reference.old_id != reference.new_id && matches!(reference.status, PushRefStatus::Ok)
})
}
}
#[derive(Clone, Copy)]
pub struct PushRequest<'a> {
pub git_dir: &'a Path,
pub common_git_dir: &'a Path,
pub format: ObjectFormat,
pub config: &'a GitConfig,
pub remote: &'a str,
pub destination: &'a PushDestination,
pub refspecs: &'a [String],
pub options: &'a PushOptions,
}
#[derive(Clone, Copy)]
pub struct PushActionRequest<'a> {
pub git_dir: &'a Path,
pub common_git_dir: &'a Path,
pub format: ObjectFormat,
pub config: &'a GitConfig,
pub remote: &'a str,
pub destination: &'a PushDestination,
pub plan: &'a PushActionPlan,
}
pub struct PushServices<'a> {
pub credentials: &'a mut dyn CredentialProvider,
pub progress: &'a mut dyn ProgressSink,
}
pub struct PushPlan {
pub commands: Vec<ReceivePackCommand>,
execution: PushExecution,
}
enum PushExecution {
Noop,
#[cfg(feature = "http")]
Http {
remote_url: RemoteUrl,
features: ReceivePackFeatures,
advertisements: Vec<RefAdvertisement>,
pack_objects: Vec<ObjectId>,
},
Ssh(crate::ssh::SshPushPlan),
Git(crate::git::GitPushPlan),
Local {
remote_git_dir: PathBuf,
remote_common_git_dir: PathBuf,
remote_refs: Vec<RefAdvertisement>,
command_forces: Vec<(ReceivePackCommand, bool)>,
pack_objects: Vec<ObjectId>,
},
}
pub fn push(request: PushRequest<'_>, mut services: PushServices<'_>) -> Result<PushOutcome> {
let plan = plan_push(request, &mut services)?;
execute_push_plan(request, &mut services, plan)
}
pub fn push_actions(
request: PushActionRequest<'_>,
mut services: PushServices<'_>,
) -> Result<PushOutcome> {
let plan = plan_push_actions(request, &mut services)?;
execute_push_action_plan(request, &mut services, plan)
}
pub fn plan_push(request: PushRequest<'_>, services: &mut PushServices<'_>) -> Result<PushPlan> {
let _ = &mut services.progress;
crate::protocol::check_transport_allowed(
scheme_for_push_destination(request.destination),
Some(request.config),
None,
)
.map_err(crate::protocol::transport_policy_git_error)?;
match request.destination {
#[cfg(feature = "http")]
PushDestination::Http(remote_url) => plan_push_http(PushHttpRequest {
git_dir: request.git_dir,
common_git_dir: request.common_git_dir,
format: request.format,
remote_url,
refspecs: request.refspecs,
options: request.options,
credentials: services.credentials,
}),
#[cfg(not(feature = "http"))]
PushDestination::Http(_) => Err(GitError::Unsupported(
"HTTP transport is not enabled in this build".into(),
)),
PushDestination::Ssh(remote_url) => {
let plan = crate::ssh::plan_push_ssh(crate::ssh::SshPushRequest {
git_dir: request.git_dir,
common_git_dir: request.common_git_dir,
format: request.format,
remote: remote_url,
refspecs: request.refspecs,
force: request.options.force,
})?;
let commands = plan.commands.clone();
let execution = if commands.is_empty() {
PushExecution::Noop
} else {
PushExecution::Ssh(plan)
};
Ok(PushPlan {
commands,
execution,
})
}
PushDestination::Git(remote_url) => {
let plan = crate::git::plan_push_git(crate::git::GitPushRequest {
git_dir: request.git_dir,
common_git_dir: request.common_git_dir,
format: request.format,
remote: remote_url,
refspecs: request.refspecs,
force: request.options.force,
})?;
let commands = plan.commands.clone();
let execution = if commands.is_empty() {
PushExecution::Noop
} else {
PushExecution::Git(plan)
};
Ok(PushPlan {
commands,
execution,
})
}
PushDestination::Local {
git_dir: remote_git_dir,
common_git_dir: remote_common_git_dir,
} => plan_push_local(PushLocalRequest {
git_dir: request.git_dir,
common_git_dir: request.common_git_dir,
format: request.format,
remote: request.remote,
remote_git_dir,
remote_common_git_dir,
refspecs: request.refspecs,
options: request.options,
}),
}
}
pub fn plan_push_actions(
request: PushActionRequest<'_>,
services: &mut PushServices<'_>,
) -> Result<PushPlan> {
let _ = &mut services.progress;
crate::protocol::check_transport_allowed(
scheme_for_push_destination(request.destination),
Some(request.config),
None,
)
.map_err(crate::protocol::transport_policy_git_error)?;
let commands = receive_pack_commands_from_action_plan(request.format, request.plan)?;
let command_forces = commands
.iter()
.cloned()
.zip(request.plan.commands.iter())
.map(|(command, planned)| (command, request.plan.options.force || planned.force))
.collect::<Vec<_>>();
match request.destination {
#[cfg(feature = "http")]
PushDestination::Http(remote_url) => {
let client = crate::http::new_http_client();
let discovered = crate::http::http_service_advertisements(
&client,
remote_url,
request.format,
GitService::ReceivePack,
services.credentials,
)?;
let advertisement_set = discovered.set;
let features = advertised_receive_pack_features(&advertisement_set.refs)?;
verify_remote_object_format(&features, request.format)?;
let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, request.format);
reject_non_fast_forward_pushes(&local_db, request.format, &command_forces)?;
let execution = if commands.is_empty() {
PushExecution::Noop
} else {
PushExecution::Http {
remote_url: remote_url.clone(),
features,
advertisements: advertisement_set.refs,
pack_objects: request.plan.pack_objects.clone(),
}
};
Ok(PushPlan {
commands,
execution,
})
}
#[cfg(not(feature = "http"))]
PushDestination::Http(_) => Err(GitError::Unsupported(
"HTTP transport is not enabled in this build".into(),
)),
PushDestination::Ssh(remote_url) => {
let plan = crate::ssh::plan_push_ssh_commands(crate::ssh::SshPushCommandsRequest {
common_git_dir: request.common_git_dir,
format: request.format,
remote: remote_url,
command_forces: command_forces.clone(),
pack_objects: request.plan.pack_objects.clone(),
})?;
let commands = plan.commands.clone();
let execution = if commands.is_empty() {
PushExecution::Noop
} else {
PushExecution::Ssh(plan)
};
Ok(PushPlan {
commands,
execution,
})
}
PushDestination::Git(remote_url) => {
let plan = crate::git::plan_push_git_commands(crate::git::GitPushCommandsRequest {
common_git_dir: request.common_git_dir,
format: request.format,
remote: remote_url,
command_forces: command_forces.clone(),
pack_objects: request.plan.pack_objects.clone(),
})?;
let commands = plan.commands.clone();
let execution = if commands.is_empty() {
PushExecution::Noop
} else {
PushExecution::Git(plan)
};
Ok(PushPlan {
commands,
execution,
})
}
PushDestination::Local {
git_dir: remote_git_dir,
common_git_dir: remote_common_git_dir,
} => {
let remote_format = crate::object_format_for_git_dir(remote_common_git_dir)?;
if remote_format != request.format {
return Err(GitError::InvalidObjectId(format!(
"remote repository uses {}, local repository uses {}",
remote_format.name(),
request.format.name()
)));
}
let remote_refs =
crate::local::local_fetch_advertisements(remote_git_dir, request.format)?;
let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, request.format);
reject_non_fast_forward_pushes(&local_db, request.format, &command_forces)?;
let execution = if commands.is_empty() {
PushExecution::Noop
} else {
PushExecution::Local {
remote_git_dir: remote_git_dir.to_path_buf(),
remote_common_git_dir: remote_common_git_dir.to_path_buf(),
remote_refs,
command_forces,
pack_objects: request.plan.pack_objects.clone(),
}
};
Ok(PushPlan {
commands,
execution,
})
}
}
}
fn scheme_for_push_destination(destination: &PushDestination) -> &'static str {
match destination {
PushDestination::Http(remote) => crate::protocol::transport_scheme_for_remote(remote),
PushDestination::Ssh(remote) => crate::protocol::transport_scheme_for_remote(remote),
PushDestination::Git(remote) => crate::protocol::transport_scheme_for_remote(remote),
PushDestination::Local { .. } => "file",
}
}
pub fn execute_push_plan(
request: PushRequest<'_>,
services: &mut PushServices<'_>,
plan: PushPlan,
) -> Result<PushOutcome> {
let _ = (request.config, request.remote);
let _ = &mut services.progress;
if plan.commands.is_empty() {
return Ok(PushOutcome::default());
}
match plan.execution {
PushExecution::Noop => Ok(PushOutcome::default()),
#[cfg(feature = "http")]
PushExecution::Http {
remote_url,
features,
advertisements,
pack_objects,
} => execute_push_http(
request,
services.credentials,
plan.commands,
remote_url,
features,
advertisements,
pack_objects,
),
PushExecution::Ssh(plan) => crate::ssh::execute_push_ssh_plan(request, plan),
PushExecution::Git(plan) => crate::git::execute_push_git_plan(request, plan),
PushExecution::Local {
remote_git_dir,
remote_common_git_dir,
remote_refs,
command_forces,
pack_objects,
} => execute_push_local(
request,
plan.commands,
remote_git_dir,
remote_common_git_dir,
remote_refs,
command_forces,
pack_objects,
),
}
}
pub fn execute_push_action_plan(
request: PushActionRequest<'_>,
services: &mut PushServices<'_>,
plan: PushPlan,
) -> Result<PushOutcome> {
let refspecs: &[String] = &[];
execute_push_plan(
PushRequest {
git_dir: request.git_dir,
common_git_dir: request.common_git_dir,
format: request.format,
config: request.config,
remote: request.remote,
destination: request.destination,
refspecs,
options: &request.plan.options,
},
services,
plan,
)
}
#[cfg(feature = "http")]
struct PushHttpRequest<'a> {
git_dir: &'a Path,
common_git_dir: &'a Path,
format: ObjectFormat,
remote_url: &'a RemoteUrl,
refspecs: &'a [String],
options: &'a PushOptions,
credentials: &'a mut dyn CredentialProvider,
}
#[cfg(feature = "http")]
fn plan_push_http(request: PushHttpRequest<'_>) -> Result<PushPlan> {
let PushHttpRequest {
git_dir,
common_git_dir,
format,
remote_url,
refspecs,
options,
credentials,
} = request;
let client = crate::http::new_http_client();
let discovered = crate::http::http_service_advertisements(
&client,
remote_url,
format,
GitService::ReceivePack,
credentials,
)?;
let advertisement_set = discovered.set;
let features = advertised_receive_pack_features(&advertisement_set.refs)?;
verify_remote_object_format(&features, format)?;
let local_store = FileRefStore::new(git_dir, format);
let mut local_refs = local_push_source_refs(&local_store, format)?;
add_revision_push_sources(git_dir, format, refspecs, &mut local_refs);
let command_forces = plan_push_command_forces(
format,
&local_refs,
&advertisement_set.refs,
refspecs,
options.force,
)?;
let local_db = FileObjectDatabase::from_git_dir(common_git_dir, format);
reject_non_fast_forward_pushes(&local_db, format, &command_forces)?;
let commands = commands_from_forces(&command_forces);
let execution = if commands.is_empty() {
PushExecution::Noop
} else {
PushExecution::Http {
remote_url: remote_url.clone(),
features,
advertisements: advertisement_set.refs,
pack_objects: Vec::new(),
}
};
Ok(PushPlan {
commands,
execution,
})
}
#[cfg(feature = "http")]
fn execute_push_http(
request: PushRequest<'_>,
credentials: &mut dyn CredentialProvider,
commands: Vec<ReceivePackCommand>,
remote_url: RemoteUrl,
features: ReceivePackFeatures,
advertisements: Vec<RefAdvertisement>,
pack_objects: Vec<ObjectId>,
) -> Result<PushOutcome> {
let client = crate::http::new_http_client();
let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, request.format);
let body = build_receive_pack_body(&PushPackRequest {
local_db: &local_db,
format: request.format,
commands: &commands,
pack_objects: &pack_objects,
remote_advertisements: &advertisements,
features: &features,
options: receive_pack_push_options(&features, request.format, request.options.quiet),
thin: false,
})?;
let url = http_smart_rpc_url(&remote_url, GitService::ReceivePack)?;
let content_type = smart_http_rpc_request_content_type(GitService::ReceivePack)?;
let mut response = crate::http::http_send_with_auth(&remote_url, credentials, |auth| {
client.post(
&url,
&content_type,
&crate::http::http_authorization_headers(auth),
&body,
)
})?;
crate::http::http_check_status(&response, &url)?;
crate::http::http_validate_content_type(
&response,
&smart_http_rpc_result_content_type(GitService::ReceivePack)?,
)?;
let report = if features.report_status {
let report = read_receive_pack_report_status(&mut response.body)?;
validate_receive_pack_report(&report)?;
Some(report)
} else {
let mut sink = Vec::new();
response.body.read_to_end(&mut sink)?;
None
};
Ok(PushOutcome { commands, report })
}
struct PushLocalRequest<'a> {
git_dir: &'a Path,
common_git_dir: &'a Path,
format: ObjectFormat,
remote: &'a str,
remote_git_dir: &'a Path,
remote_common_git_dir: &'a Path,
refspecs: &'a [String],
options: &'a PushOptions,
}
fn plan_push_local(request: PushLocalRequest<'_>) -> Result<PushPlan> {
let PushLocalRequest {
git_dir,
common_git_dir,
format,
remote,
remote_git_dir,
remote_common_git_dir,
refspecs,
options,
} = request;
let _ = remote;
let remote_format = crate::object_format_for_git_dir(remote_common_git_dir)?;
if remote_format != format {
return Err(GitError::InvalidObjectId(format!(
"remote repository uses {}, local repository uses {}",
remote_format.name(),
format.name()
)));
}
let local_store = FileRefStore::new(git_dir, format);
let mut local_refs = local_push_source_refs(&local_store, format)?;
add_revision_push_sources(git_dir, format, refspecs, &mut local_refs);
let remote_refs = crate::local::local_fetch_advertisements(remote_git_dir, format)?;
let command_forces =
plan_push_command_forces(format, &local_refs, &remote_refs, refspecs, options.force)?;
let local_db = FileObjectDatabase::from_git_dir(common_git_dir, format);
reject_non_fast_forward_pushes(&local_db, format, &command_forces)?;
let commands = commands_from_forces(&command_forces);
let execution = if commands.is_empty() {
PushExecution::Noop
} else {
PushExecution::Local {
remote_git_dir: remote_git_dir.to_path_buf(),
remote_common_git_dir: remote_common_git_dir.to_path_buf(),
remote_refs,
command_forces,
pack_objects: Vec::new(),
}
};
Ok(PushPlan {
commands,
execution,
})
}
fn execute_push_local(
request: PushRequest<'_>,
commands: Vec<ReceivePackCommand>,
remote_git_dir: PathBuf,
remote_common_git_dir: PathBuf,
remote_refs: Vec<RefAdvertisement>,
_command_forces: Vec<(ReceivePackCommand, bool)>,
pack_objects: Vec<ObjectId>,
) -> Result<PushOutcome> {
let remote_excluded_tips = remote_refs
.iter()
.map(|reference| reference.oid)
.collect::<Vec<_>>();
let starts = push_pack_roots(&commands, &pack_objects);
let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, request.format);
let remote_db = FileObjectDatabase::from_git_dir(&remote_common_git_dir, request.format);
let remote_excluded =
collect_reachable_object_ids(&remote_db, request.format, remote_excluded_tips)?;
let packfile = if starts.is_empty() {
Vec::new()
} else {
b"PACK".to_vec()
};
let receive_request = ReceivePackPushRequest {
commands: ReceivePackRequest {
shallow: Vec::new(),
commands: commands.clone(),
capabilities: Vec::new(),
},
push_options: None,
packfile,
};
let report = crate::local::receive_pack_reachable_pack_into_local_repository(
&remote_git_dir,
request.format,
&receive_request,
&local_db,
starts,
remote_excluded,
)?;
validate_receive_pack_report(&report)?;
Ok(PushOutcome {
commands,
report: Some(report),
})
}
pub struct PushReportRequest<'a> {
pub git_dir: &'a Path,
pub common_git_dir: &'a Path,
pub format: ObjectFormat,
pub remote_git_dir: &'a Path,
pub remote_common_git_dir: &'a Path,
pub refspecs: &'a [String],
pub force: bool,
pub atomic: bool,
pub dry_run: bool,
pub force_with_lease: &'a [(String, Option<ObjectId>)],
pub force_with_lease_default: bool,
pub force_if_includes: bool,
pub receive_config_overrides: &'a [(String, String)],
}
pub fn push_local_with_report(
request: PushReportRequest<'_>,
_config: &GitConfig,
) -> Result<PushStatusReport> {
let format = request.format;
let remote_format = crate::object_format_for_git_dir(request.remote_common_git_dir)?;
if remote_format != format {
return Err(GitError::InvalidObjectId(format!(
"remote repository uses {}, local repository uses {}",
remote_format.name(),
format.name()
)));
}
let local_store = FileRefStore::new(request.git_dir, format);
let mut local_refs = local_push_source_refs(&local_store, format)?;
add_revision_push_sources(request.git_dir, format, request.refspecs, &mut local_refs);
let remote_refs = crate::local::local_fetch_advertisements(request.remote_git_dir, format)?;
let planned = plan_push_command_sources(
format,
&local_refs,
&remote_refs,
request.refspecs,
request.force,
)?;
let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, format);
let remote_config =
sley_config::read_repo_config(request.remote_git_dir, None).unwrap_or_default();
let mut refs: Vec<PushReportRef> = Vec::new();
for plan in &planned {
let status = classify_push_command(
&local_db,
format,
plan,
&request,
&remote_config,
request.remote_git_dir,
)?;
let stale_lease_overridden = plan.force && lease_expectation_mismatch(&request, plan);
let forced = matches!(status, PushRefStatus::Ok)
&& !plan.command.old_id.is_null()
&& !plan.command.new_id.is_null()
&& (stale_lease_overridden
|| if plan.command.name.starts_with("refs/heads/") {
!is_fast_forward(
&local_db,
format,
&plan.command.old_id,
&plan.command.new_id,
)?
} else {
plan.force
});
refs.push(PushReportRef {
src: plan.source.clone(),
dst: plan.command.name.clone(),
old_id: plan.command.old_id,
new_id: plan.command.new_id,
forced,
status,
});
}
let any_local_reject = refs.iter().any(|reference| {
matches!(
reference.status,
PushRefStatus::RejectNonFastForward
| PushRefStatus::RejectStale
| PushRefStatus::RejectRemoteUpdated
| PushRefStatus::RejectAlreadyExists
)
});
if request.atomic && any_local_reject {
for reference in &mut refs {
if matches!(reference.status, PushRefStatus::Ok) {
reference.status = PushRefStatus::AtomicPushFailed;
}
}
return Ok(PushStatusReport { refs });
}
if request.dry_run {
return Ok(PushStatusReport { refs });
}
let send: Vec<ReceivePackCommand> = refs
.iter()
.filter(|reference| {
matches!(reference.status, PushRefStatus::Ok) && reference.old_id != reference.new_id
})
.map(|reference| ReceivePackCommand {
old_id: reference.old_id,
new_id: reference.new_id,
name: reference.dst.clone(),
})
.collect();
if !send.is_empty() {
let remote_excluded_tips: Vec<ObjectId> =
remote_refs.iter().map(|reference| reference.oid).collect();
let pack_objects: Vec<ObjectId> = Vec::new();
let starts = push_pack_roots(&send, &pack_objects);
let remote_db = FileObjectDatabase::from_git_dir(request.remote_common_git_dir, format);
let remote_excluded =
collect_reachable_object_ids(&remote_db, format, remote_excluded_tips)?;
let packfile = if starts.is_empty() {
Vec::new()
} else {
b"PACK".to_vec()
};
let receive_request = ReceivePackPushRequest {
commands: ReceivePackRequest {
shallow: Vec::new(),
commands: send.clone(),
capabilities: Vec::new(),
},
push_options: None,
packfile,
};
let report = crate::local::receive_pack_reachable_pack_into_local_repository(
request.remote_git_dir,
format,
&receive_request,
&local_db,
starts,
remote_excluded,
)?;
if let ReceivePackUnpackStatus::Error(message) = &report.unpack {
for reference in &mut refs {
if matches!(reference.status, PushRefStatus::Ok) {
reference.status =
PushRefStatus::RemoteReject(format!("unpacker error: {message}"));
}
}
}
for command_status in &report.commands {
if let ReceivePackCommandStatus::Ng { name, message } = command_status {
for reference in &mut refs {
if reference.dst == *name && matches!(reference.status, PushRefStatus::Ok) {
reference.status = PushRefStatus::RemoteReject(message.clone());
}
}
}
}
}
Ok(PushStatusReport { refs })
}
fn classify_push_command(
local_db: &FileObjectDatabase,
format: ObjectFormat,
plan: &PlannedPushCommand,
request: &PushReportRequest<'_>,
config: &GitConfig,
remote_git_dir: &Path,
) -> Result<PushRefStatus> {
let command = &plan.command;
if receive_ref_is_hidden(config, request.receive_config_overrides, &command.name) {
let reason = if command.new_id.is_null() {
"deny deleting a hidden ref"
} else {
"deny updating a hidden ref"
};
return Ok(PushRefStatus::RemoteReject(reason.to_string()));
}
if command.old_id == command.new_id && !command.new_id.is_null() {
return Ok(PushRefStatus::UpToDate);
}
if command.new_id.is_null() && !command.old_id.is_null() {
if receive_config_bool(config, request.receive_config_overrides, "denydeletes")
.unwrap_or(false)
{
return Ok(PushRefStatus::RemoteReject(
"deletion prohibited".to_string(),
));
}
if receive_denies_current_branch_delete(format, command, config, request, remote_git_dir)? {
return Ok(PushRefStatus::RemoteReject(
"deletion of the current branch prohibited".to_string(),
));
}
}
if !request.dry_run && receive_denies_current_branch(format, command, config, remote_git_dir)? {
return Ok(PushRefStatus::RemoteReject(
"branch is currently checked out".to_string(),
));
}
if command.name.starts_with("refs/heads/") && !command.new_id.is_null() {
let object = local_db.read_object(&command.new_id)?;
if object.object_type != ObjectType::Commit {
return Ok(PushRefStatus::RemoteReject(
"invalid new value provided".to_string(),
));
}
}
if let Some((_, expected)) = request
.force_with_lease
.iter()
.find(|(dst, _)| *dst == command.name)
{
let actual = if command.old_id.is_null() {
None
} else {
Some(command.old_id)
};
if *expected != actual {
if plan.force {
return Ok(PushRefStatus::Ok);
}
return Ok(PushRefStatus::RejectStale);
}
if request.force_if_includes
&& !command.old_id.is_null()
&& (command.new_id.is_null()
|| !is_fast_forward(local_db, format, &command.old_id, &command.new_id)?)
&& force_if_includes_rejects(
local_db,
format,
request.git_dir,
&command.name,
&command.old_id,
)?
{
if plan.force {
return Ok(PushRefStatus::Ok);
}
return Ok(PushRefStatus::RejectRemoteUpdated);
}
return Ok(PushRefStatus::Ok);
}
if command.name.starts_with("refs/heads/")
&& !command.old_id.is_null()
&& !command.new_id.is_null()
&& !is_fast_forward(local_db, format, &command.old_id, &command.new_id)?
&& receive_config_bool(
config,
request.receive_config_overrides,
"denynonfastforwards",
)
.unwrap_or(false)
{
return Ok(PushRefStatus::RemoteReject(format!(
"denying non-fast-forward {} (you should pull first)",
command.name
)));
}
if !plan.force
&& command.name.starts_with("refs/tags/")
&& !command.old_id.is_null()
&& !command.new_id.is_null()
{
return Ok(PushRefStatus::RejectAlreadyExists);
}
if !plan.force
&& command.name.starts_with("refs/heads/")
&& !command.old_id.is_null()
&& !command.new_id.is_null()
&& !is_fast_forward(local_db, format, &command.old_id, &command.new_id)?
{
return Ok(PushRefStatus::RejectNonFastForward);
}
Ok(PushRefStatus::Ok)
}
fn receive_ref_is_hidden(
config: &GitConfig,
overrides: &[(String, String)],
refname: &str,
) -> bool {
let mut hide_refs = Vec::new();
hide_refs.extend(hidden_ref_values(config, "transfer", None));
hide_refs.extend(hidden_ref_values(config, "receive", None));
hide_refs.extend(
overrides
.iter()
.filter(|(key, _)| key.eq_ignore_ascii_case("hiderefs"))
.map(|(_, value)| trim_hidden_ref_pattern(value)),
);
ref_is_hidden_by_patterns(refname, &hide_refs)
}
fn hidden_ref_values(config: &GitConfig, section: &str, subsection: Option<&str>) -> Vec<String> {
config
.get_all(section, subsection, "hiderefs")
.into_iter()
.flatten()
.map(trim_hidden_ref_pattern)
.collect()
}
fn trim_hidden_ref_pattern(value: &str) -> String {
value.trim_end_matches('/').to_string()
}
fn ref_is_hidden_by_patterns(refname: &str, patterns: &[String]) -> bool {
for pattern in patterns.iter().rev() {
let mut pattern = pattern.as_str();
let negated = pattern.strip_prefix('!').is_some();
if negated {
pattern = &pattern[1..];
}
if let Some(rest) = pattern.strip_prefix('^') {
pattern = rest;
}
if hidden_ref_pattern_matches(refname, pattern) {
return !negated;
}
}
false
}
fn hidden_ref_pattern_matches(refname: &str, pattern: &str) -> bool {
refname
.strip_prefix(pattern)
.is_some_and(|rest| rest.is_empty() || rest.starts_with('/'))
}
fn lease_expectation_mismatch(request: &PushReportRequest<'_>, plan: &PlannedPushCommand) -> bool {
let command = &plan.command;
let actual = if command.old_id.is_null() {
None
} else {
Some(command.old_id)
};
request
.force_with_lease
.iter()
.find(|(dst, _)| *dst == command.name)
.is_some_and(|(_, expected)| *expected != actual)
}
fn force_if_includes_rejects(
db: &FileObjectDatabase,
format: ObjectFormat,
git_dir: &Path,
local_ref: &str,
remote_old: &ObjectId,
) -> Result<bool> {
let store = FileRefStore::new(git_dir, format);
let mut candidates = Vec::new();
match store.read_ref(local_ref)? {
Some(RefTarget::Direct(oid)) => candidates.push(oid),
Some(RefTarget::Symbolic(target)) => {
if let Some(RefTarget::Direct(oid)) = store.read_ref(&target)? {
candidates.push(oid);
}
}
None => return Ok(false),
}
for entry in store.read_reflog(local_ref)? {
if !entry.new_oid.is_null() {
candidates.push(entry.new_oid);
}
}
candidates.sort();
candidates.dedup();
for candidate in candidates {
if candidate == *remote_old {
return Ok(false);
}
if let Ok(ancestors) = ancestor_depths(db, format, &candidate)
&& ancestors.contains_key(remote_old)
{
return Ok(false);
}
}
Ok(true)
}
fn receive_config_bool(
config: &GitConfig,
overrides: &[(String, String)],
key: &str,
) -> Option<bool> {
overrides
.iter()
.rev()
.find(|(candidate, _)| candidate.eq_ignore_ascii_case(key))
.and_then(|(_, value)| sley_config::parse_config_bool(value))
.or_else(|| config.get_bool("receive", None, key))
}
fn receive_denies_current_branch(
format: ObjectFormat,
command: &ReceivePackCommand,
config: &GitConfig,
remote_git_dir: &Path,
) -> Result<bool> {
if command.new_id.is_null() {
return Ok(false);
}
if !command.name.starts_with("refs/heads/") {
return Ok(false);
}
let deny = config
.get("receive", None, "denycurrentbranch")
.unwrap_or("refuse");
let denies = matches!(
deny.to_ascii_lowercase().as_str(),
"true" | "yes" | "on" | "1" | "refuse"
);
if !denies {
return Ok(false);
}
if sley_worktree::worktree_root_for_git_dir(remote_git_dir)?.is_none() {
return Ok(false);
}
let store = FileRefStore::new(remote_git_dir, format);
Ok(matches!(
store.read_ref("HEAD")?,
Some(RefTarget::Symbolic(target)) if target == command.name
))
}
fn receive_targets_current_branch(
format: ObjectFormat,
command: &ReceivePackCommand,
remote_git_dir: &Path,
) -> Result<bool> {
if !command.name.starts_with("refs/heads/") {
return Ok(false);
}
if sley_worktree::worktree_root_for_git_dir(remote_git_dir)?.is_none() {
return Ok(false);
}
let store = FileRefStore::new(remote_git_dir, format);
Ok(matches!(
store.read_ref("HEAD")?,
Some(RefTarget::Symbolic(target)) if target == command.name
))
}
fn receive_denies_current_branch_delete(
format: ObjectFormat,
command: &ReceivePackCommand,
config: &GitConfig,
request: &PushReportRequest<'_>,
remote_git_dir: &Path,
) -> Result<bool> {
if !receive_targets_current_branch(format, command, remote_git_dir)? {
return Ok(false);
}
let deny = request
.receive_config_overrides
.iter()
.rev()
.find(|(candidate, _)| candidate.eq_ignore_ascii_case("denydeletecurrent"))
.map(|(_, value)| value.as_str())
.or_else(|| config.get("receive", None, "denydeletecurrent"))
.unwrap_or("refuse");
Ok(!matches!(
deny.to_ascii_lowercase().as_str(),
"ignore" | "warn" | "false" | "no" | "off" | "0"
))
}
fn is_fast_forward(
db: &FileObjectDatabase,
format: ObjectFormat,
old: &ObjectId,
new: &ObjectId,
) -> Result<bool> {
let ancestors = ancestor_depths(db, format, new)?;
Ok(ancestors.contains_key(old))
}
#[cfg(feature = "http")]
fn advertised_receive_pack_features(
advertisements: &[RefAdvertisement],
) -> Result<ReceivePackFeatures> {
advertisements
.first()
.map(|advertisement| parse_receive_pack_features(&advertisement.capabilities))
.transpose()
.map(Option::unwrap_or_default)
}
#[cfg(feature = "http")]
fn verify_remote_object_format(features: &ReceivePackFeatures, format: ObjectFormat) -> Result<()> {
if let Some(remote_format) = features.object_format {
if remote_format != format {
return Err(GitError::InvalidObjectId(format!(
"remote repository uses {}, local repository uses {}",
remote_format.name(),
format.name()
)));
}
} else if format != ObjectFormat::Sha1 {
return Err(GitError::InvalidObjectId(format!(
"remote repository did not advertise object-format for {} push",
format.name()
)));
}
Ok(())
}
#[cfg(feature = "http")]
fn receive_pack_push_options(
features: &ReceivePackFeatures,
format: ObjectFormat,
quiet: bool,
) -> ReceivePackPushRequestOptions {
ReceivePackPushRequestOptions {
report_status: features.report_status,
ofs_delta: features.ofs_delta,
quiet: quiet && features.quiet,
object_format: features
.object_format
.filter(|_| format != ObjectFormat::Sha1),
..ReceivePackPushRequestOptions::default()
}
}
pub(crate) fn plan_push_command_forces(
format: ObjectFormat,
local_refs: &[PushSourceRef],
remote_refs: &[RefAdvertisement],
refspecs: &[String],
force: bool,
) -> Result<Vec<(ReceivePackCommand, bool)>> {
let parsed_refspecs = refspecs
.iter()
.map(|refspec| {
let normalized = normalize_push_refspec_for_sources(refspec, local_refs, remote_refs)?;
parse_refspec(&normalized)
})
.collect::<Result<Vec<_>>>()?;
let mut command_forces = Vec::new();
for refspec in &parsed_refspecs {
for command in plan_push_commands(
format,
local_refs,
remote_refs,
std::slice::from_ref(refspec),
)? {
command_forces.push((command, force || refspec.force));
}
}
Ok(command_forces)
}
struct PlannedPushCommand {
command: ReceivePackCommand,
force: bool,
source: Option<String>,
}
fn plan_push_command_sources(
format: ObjectFormat,
local_refs: &[PushSourceRef],
remote_refs: &[RefAdvertisement],
refspecs: &[String],
force: bool,
) -> Result<Vec<PlannedPushCommand>> {
let mut planned = Vec::new();
for refspec in refspecs {
let normalized = normalize_push_refspec_for_sources(refspec, local_refs, remote_refs)?;
let parsed = parse_refspec(&normalized)?;
let commands = plan_push_commands(
format,
local_refs,
remote_refs,
std::slice::from_ref(&parsed),
)?;
for command in commands {
let source = push_command_source_name(&parsed, &command);
planned.push(PlannedPushCommand {
command,
force: force || parsed.force,
source,
});
}
}
Ok(planned)
}
fn push_command_source_name(refspec: &RefSpec, command: &ReceivePackCommand) -> Option<String> {
let src = refspec.src.as_deref()?;
if !refspec.pattern {
return Some(src.to_string());
}
let (src_prefix, src_suffix) = src.split_once('*')?;
let dst = refspec.dst.as_deref()?;
let (dst_prefix, dst_suffix) = dst.split_once('*')?;
let stem = command
.name
.strip_prefix(dst_prefix)
.and_then(|rest| rest.strip_suffix(dst_suffix))?;
Some(format!("{src_prefix}{stem}{src_suffix}"))
}
pub(crate) fn add_revision_push_sources(
git_dir: &Path,
format: ObjectFormat,
refspecs: &[String],
local_refs: &mut Vec<PushSourceRef>,
) {
for refspec in refspecs {
let refspec = refspec.strip_prefix('+').unwrap_or(refspec);
let src = refspec.split_once(':').map_or(refspec, |(src, _)| src);
if src.is_empty() || src == "HEAD" {
continue;
}
if src.starts_with("refs/") && local_refs.iter().any(|reference| reference.name == src) {
continue;
}
if local_refs.iter().any(|reference| {
reference.name == src
|| reference.name == format!("refs/heads/{src}")
|| reference.name == format!("refs/tags/{src}")
}) {
continue;
}
if let Ok(oid) = sley_rev::resolve_revision(git_dir, format, src)
&& !local_refs.iter().any(|reference| reference.name == src)
{
local_refs.push(PushSourceRef {
name: src.to_string(),
oid,
});
}
}
}
fn normalize_push_refspec_for_sources(
refspec: &str,
local_refs: &[PushSourceRef],
remote_refs: &[RefAdvertisement],
) -> Result<String> {
let (force, refspec) = refspec
.strip_prefix('+')
.map_or((false, refspec), |refspec| (true, refspec));
let normalized = if let Some((src, dst)) = refspec.split_once(':') {
let (src, src_kind) = normalize_push_source_refname(src, local_refs);
let dst = if src.is_empty() {
normalize_push_delete_destination_refname(dst, remote_refs)?
} else {
normalize_push_destination_refname(dst, src_kind, remote_refs)?
};
if !src.is_empty() && !dst.contains('*') && push_destination_is_onelevel_under_refs(&dst) {
return Err(GitError::Command(format!(
"destination refspec {dst} is not a valid ref"
)));
}
format!("{src}:{dst}")
} else {
let (name, _) = normalize_push_source_refname(refspec, local_refs);
let dst = match count_refspec_match_dst(&name, remote_refs) {
DstMatch::Unique(matched) => matched.to_string(),
DstMatch::None => name.clone(),
DstMatch::Ambiguous => {
return Err(GitError::Command(format!(
"dst refspec {name} matches more than one"
)));
}
};
format!("{name}:{dst}")
};
Ok(if force {
format!("+{normalized}")
} else {
normalized
})
}
fn refname_match_rank(abbrev: &str, full_name: &str) -> Option<usize> {
const RULES: [&str; 6] = [
"{}",
"refs/{}",
"refs/tags/{}",
"refs/heads/{}",
"refs/remotes/{}",
"refs/remotes/{}/HEAD",
];
for (idx, rule) in RULES.iter().enumerate() {
let (prefix, suffix) = rule.split_once("{}").unwrap_or((rule, ""));
if full_name == format!("{prefix}{abbrev}{suffix}") {
return Some(RULES.len() - idx);
}
}
None
}
enum DstMatch<'a> {
Unique(&'a str),
None,
Ambiguous,
}
fn count_refspec_match_dst<'a>(pattern: &str, remote_refs: &'a [RefAdvertisement]) -> DstMatch<'a> {
let patlen = pattern.len();
let mut strong: Option<&str> = None;
let mut strong_count = 0usize;
let mut weak: Option<&str> = None;
let mut weak_count = 0usize;
for advert in remote_refs {
let name = advert.name.as_str();
if refname_match_rank(pattern, name).is_none() {
continue;
}
let namelen = name.len();
let is_weak = namelen != patlen
&& patlen + 5 != namelen
&& !name.starts_with("refs/heads/")
&& !name.starts_with("refs/tags/");
if is_weak {
weak = Some(name);
weak_count += 1;
} else {
strong = Some(name);
strong_count += 1;
}
}
match (strong_count, weak_count, strong, weak) {
(1, _, Some(matched), _) => DstMatch::Unique(matched),
(0, 1, _, Some(matched)) => DstMatch::Unique(matched),
(0, 0, _, _) => DstMatch::None,
_ => DstMatch::Ambiguous,
}
}
#[derive(Clone, Copy)]
enum PushSourceKind {
Branch,
Tag,
Other,
Unqualifiable,
}
fn normalize_push_source_refname(
name: &str,
local_refs: &[PushSourceRef],
) -> (String, PushSourceKind) {
if name.is_empty() || name == "HEAD" || name == "@" || name.starts_with("refs/") {
return (name.to_string(), PushSourceKind::Other);
}
let branch = format!("refs/heads/{name}");
let tag = format!("refs/tags/{name}");
let has_branch = local_refs.iter().any(|reference| reference.name == branch);
let has_tag = local_refs.iter().any(|reference| reference.name == tag);
if has_tag && !has_branch {
(tag, PushSourceKind::Tag)
} else if has_branch {
(branch, PushSourceKind::Branch)
} else if local_refs.iter().any(|reference| reference.name == name) {
(name.to_string(), PushSourceKind::Unqualifiable)
} else {
(branch, PushSourceKind::Branch)
}
}
fn normalize_push_delete_destination_refname(
name: &str,
remote_refs: &[RefAdvertisement],
) -> Result<String> {
if name.is_empty() || name == "HEAD" || name.starts_with("refs/") {
return Ok(name.to_string());
}
match count_refspec_match_dst(name, remote_refs) {
DstMatch::Unique(matched) => Ok(matched.to_string()),
DstMatch::Ambiguous => Err(GitError::Command(format!(
"dst refspec {name} matches more than one"
))),
DstMatch::None => Err(GitError::reference_not_found(format!("remote ref {name}"))),
}
}
fn normalize_push_destination_refname(
name: &str,
src_kind: PushSourceKind,
remote_refs: &[RefAdvertisement],
) -> Result<String> {
if name.is_empty() || name == "HEAD" || name.starts_with("refs/") {
return Ok(name.to_string());
}
match count_refspec_match_dst(name, remote_refs) {
DstMatch::Unique(matched) => Ok(matched.to_string()),
DstMatch::Ambiguous => Err(GitError::Command(format!(
"dst refspec {name} matches more than one"
))),
DstMatch::None => match src_kind {
PushSourceKind::Tag => Ok(format!("refs/tags/{name}")),
PushSourceKind::Branch | PushSourceKind::Other => Ok(format!("refs/heads/{name}")),
PushSourceKind::Unqualifiable => Err(GitError::Command(format!(
"the destination you provided is not a full refname (i.e., starting with \"refs/\"); unable to guess the destination for {name}"
))),
},
}
}
fn push_destination_is_onelevel_under_refs(name: &str) -> bool {
name.strip_prefix("refs/")
.is_some_and(|rest| !rest.contains('/'))
}
fn commands_from_forces(command_forces: &[(ReceivePackCommand, bool)]) -> Vec<ReceivePackCommand> {
command_forces
.iter()
.map(|(command, _)| command.clone())
.collect()
}
fn receive_pack_commands_from_action_plan(
format: ObjectFormat,
plan: &PushActionPlan,
) -> Result<Vec<ReceivePackCommand>> {
let zero = ObjectId::null(format);
for oid in &plan.pack_objects {
if oid.format() != format {
return Err(GitError::InvalidObjectId(format!(
"push pack object {oid} has {} object id for {} repository",
oid.format().name(),
format.name()
)));
}
}
plan.commands
.iter()
.map(|command| {
let old_id = command.expected_old.unwrap_or(zero);
let new_id = command.src.unwrap_or(zero);
if old_id.format() != format {
return Err(GitError::InvalidObjectId(format!(
"push command {} expected old has {} object id for {} repository",
command.dst,
old_id.format().name(),
format.name()
)));
}
if new_id.format() != format {
return Err(GitError::InvalidObjectId(format!(
"push command {} new id has {} object id for {} repository",
command.dst,
new_id.format().name(),
format.name()
)));
}
Ok(ReceivePackCommand {
old_id,
new_id,
name: command.dst.clone(),
})
})
.collect()
}
pub fn validate_receive_pack_report(report: &ReceivePackReportStatus) -> Result<()> {
if let ReceivePackUnpackStatus::Error(message) = &report.unpack {
return Err(GitError::Command(format!(
"failed to push some refs: unpack failed: {message}"
)));
}
for status in &report.commands {
if let ReceivePackCommandStatus::Ng { name, message } = status {
return Err(GitError::Command(format!(
"failed to push {name}: {message}"
)));
}
}
Ok(())
}
pub fn local_push_source_refs(
store: &FileRefStore,
format: ObjectFormat,
) -> Result<Vec<PushSourceRef>> {
let mut refs = Vec::new();
for reference in store.list_refs()? {
let Some((oid, _)) = resolve_for_each_ref_target(store, &reference)? else {
continue;
};
if oid.format() != format {
return Err(GitError::InvalidObjectId(format!(
"local ref {} has {} object id for {} repository",
reference.name,
oid.format().name(),
format.name()
)));
}
refs.push(PushSourceRef {
name: reference.name.clone(),
oid,
});
if let Some(short) = reference.name.strip_prefix("refs/heads/") {
refs.push(PushSourceRef {
name: short.to_string(),
oid,
});
}
if let Some(short) = reference.name.strip_prefix("refs/tags/") {
refs.push(PushSourceRef {
name: short.to_string(),
oid,
});
}
}
if let Some(target) = store.read_ref("HEAD")? {
let head = Ref {
name: "HEAD".to_string(),
target,
};
if let Some((oid, _)) = resolve_for_each_ref_target(store, &head)?
&& oid.format() == format
{
refs.push(PushSourceRef {
name: "HEAD".to_string(),
oid,
});
}
}
Ok(refs)
}
pub fn normalize_push_refspec(refspec: &str) -> String {
let (force, refspec) = refspec
.strip_prefix('+')
.map_or((false, refspec), |refspec| (true, refspec));
let normalized = if let Some((src, dst)) = refspec.split_once(':') {
let src = normalize_push_refname(src);
let dst = normalize_push_refname(dst);
format!("{src}:{dst}")
} else {
let name = normalize_push_refname(refspec);
format!("{name}:{name}")
};
if force {
format!("+{normalized}")
} else {
normalized
}
}
pub fn normalize_push_refname(name: &str) -> String {
if name.is_empty() || name == "HEAD" || name.starts_with("refs/") {
name.to_string()
} else {
format!("refs/heads/{name}")
}
}
pub fn reject_non_fast_forward_pushes(
local_db: &FileObjectDatabase,
format: ObjectFormat,
command_forces: &[(ReceivePackCommand, bool)],
) -> Result<()> {
for (command, force) in command_forces {
if *force
|| !command.name.starts_with("refs/heads/")
|| command.old_id.is_null()
|| command.new_id.is_null()
{
continue;
}
let ancestors = ancestor_depths(local_db, format, &command.new_id)?;
if !ancestors.contains_key(&command.old_id) {
let short = command.name.trim_start_matches("refs/heads/");
return Err(GitError::Command(format!(
"failed to push some refs: non-fast-forward update to {short}"
)));
}
}
Ok(())
}
fn ancestor_depths(
db: &FileObjectDatabase,
format: ObjectFormat,
start: &ObjectId,
) -> Result<HashMap<ObjectId, usize>> {
let mut depths = HashMap::new();
let mut pending = std::collections::VecDeque::from([(start.clone(), 0usize)]);
while let Some((oid, depth)) = pending.pop_front() {
if depths.get(&oid).is_some_and(|existing| *existing <= depth) {
continue;
}
depths.insert(oid, depth);
let object = db.read_object(&oid)?;
if object.object_type != ObjectType::Commit {
return Err(GitError::InvalidObject(format!(
"expected commit {oid}, found {}",
object.object_type.as_str()
)));
}
let commit = Commit::parse_ref(format, &object.body)?;
for parent in commit.parents {
pending.push_back((parent, depth + 1));
}
}
Ok(depths)
}
fn resolve_for_each_ref_target(
store: &FileRefStore,
reference: &Ref,
) -> Result<Option<(ObjectId, Option<String>)>> {
let mut target = reference.target.clone();
let mut symref = None;
for _ in 0..5 {
match target {
RefTarget::Direct(oid) => return Ok(Some((oid, symref))),
RefTarget::Symbolic(name) => {
symref.get_or_insert_with(|| name.clone());
let Some(next) = store.read_ref(&name)? else {
return Ok(None);
};
target = next;
}
}
}
Ok(None)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::sync::atomic::{AtomicU64, Ordering};
use sley_formats::RepositoryLayout;
use sley_object::{Commit, EncodedObject, ObjectType, Tree};
use sley_odb::{FileObjectDatabase, ObjectWriter};
use sley_protocol::{ReceivePackCommandStatus, ReceivePackUnpackStatus};
use sley_refs::{RefTarget, RefUpdate};
use crate::{NoCredentials, SilentProgress};
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
fn temp_repo(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"sley-remote-push-{name}-{}-{}",
std::process::id(),
TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
));
let _ = fs::remove_dir_all(&dir);
RepositoryLayout::init_at(&dir, ObjectFormat::Sha1, false)
.expect("test repository should initialize");
dir.join(".git")
}
fn write_commit(git_dir: &Path, parents: Vec<ObjectId>, message: &str) -> ObjectId {
let format = ObjectFormat::Sha1;
let db = FileObjectDatabase::from_git_dir(git_dir, format);
let tree = db
.write_object(EncodedObject::new(
ObjectType::Tree,
Tree { entries: vec![] }.write(),
))
.expect("tree should write");
let identity = b"Test User <test@example.invalid> 1 +0000".to_vec();
db.write_object(EncodedObject::new(
ObjectType::Commit,
Commit {
tree,
parents,
author: identity.clone(),
committer: identity,
encoding: None,
message: format!("{message}\n").into_bytes(),
}
.write(),
))
.expect("commit should write")
}
fn set_ref(git_dir: &Path, name: &str, target: RefTarget) {
let store = FileRefStore::new(git_dir, ObjectFormat::Sha1);
let mut tx = store.transaction();
tx.update(RefUpdate {
name: name.to_string(),
expected: None,
new: target,
reflog: None,
});
tx.commit().expect("ref should update");
}
fn default_options() -> PushOptions {
PushOptions {
quiet: true,
force: false,
}
}
#[test]
fn push_action_plan_infers_pack_roots_from_non_delete_commands() {
let repo = temp_repo("action-plan-infer-roots");
let first = write_commit(&repo, Vec::new(), "first");
let second = write_commit(&repo, vec![first], "second");
let plan = PushActionPlan::from_commands_and_infer_pack_roots(
vec![
PushCommand {
src: Some(first),
dst: "refs/heads/main".into(),
expected_old: None,
force: false,
},
PushCommand {
src: Some(second),
dst: "refs/heads/topic".into(),
expected_old: Some(first),
force: true,
},
],
default_options(),
);
assert_eq!(plan.pack_objects, vec![first, second]);
assert!(!plan.commands[0].force);
assert!(plan.commands[1].force);
}
#[test]
fn push_action_plan_inferred_pack_roots_exclude_deletes() {
let repo = temp_repo("action-plan-delete-roots");
let old = write_commit(&repo, Vec::new(), "old");
let new = write_commit(&repo, vec![old], "new");
let plan = PushActionPlan::from_commands_and_infer_pack_roots(
vec![
PushCommand {
src: None,
dst: "refs/heads/remove".into(),
expected_old: Some(old),
force: false,
},
PushCommand {
src: Some(new),
dst: "refs/heads/keep".into(),
expected_old: Some(old),
force: false,
},
],
default_options(),
);
assert_eq!(plan.pack_objects, vec![new]);
}
#[test]
fn push_action_plan_inferred_pack_roots_dedupe_first_seen_order() {
let repo = temp_repo("action-plan-dedupe-roots");
let first = write_commit(&repo, Vec::new(), "first");
let second = write_commit(&repo, Vec::new(), "second");
let plan = PushActionPlan::from_commands_and_infer_pack_roots(
vec![
PushCommand {
src: Some(second),
dst: "refs/heads/second".into(),
expected_old: None,
force: false,
},
PushCommand {
src: Some(first),
dst: "refs/heads/first".into(),
expected_old: None,
force: false,
},
PushCommand {
src: Some(second),
dst: "refs/tags/second".into(),
expected_old: None,
force: false,
},
PushCommand {
src: Some(first),
dst: "refs/tags/first".into(),
expected_old: None,
force: false,
},
],
default_options(),
);
assert_eq!(plan.pack_objects, vec![second, first]);
}
fn push_local_actions(
local: &Path,
remote: &Path,
plan: &PushActionPlan,
) -> Result<PushOutcome> {
let destination = PushDestination::Local {
git_dir: remote.to_path_buf(),
common_git_dir: remote.to_path_buf(),
};
let config = GitConfig::default();
let mut credentials = NoCredentials;
let mut progress = SilentProgress;
push_actions(
PushActionRequest {
git_dir: local,
common_git_dir: local,
format: ObjectFormat::Sha1,
config: &config,
remote: "origin",
destination: &destination,
plan,
},
PushServices {
credentials: &mut credentials,
progress: &mut progress,
},
)
}
#[test]
fn local_push_returns_success_report_status_and_updates_ref() {
let local = temp_repo("local-success");
let remote = temp_repo("remote-success");
let base = write_commit(&local, Vec::new(), "base");
let tip = write_commit(&local, vec![base], "tip");
set_ref(&local, "refs/heads/main", RefTarget::Direct(tip));
set_ref(
&local,
"HEAD",
RefTarget::Symbolic("refs/heads/main".into()),
);
let destination = PushDestination::Local {
git_dir: remote.clone(),
common_git_dir: remote.clone(),
};
let refspecs = vec!["refs/heads/main:refs/heads/main".to_string()];
let options = default_options();
let request = PushRequest {
git_dir: &local,
common_git_dir: &local,
format: ObjectFormat::Sha1,
config: &GitConfig::default(),
remote: "origin",
destination: &destination,
refspecs: &refspecs,
options: &options,
};
let mut credentials = NoCredentials;
let mut progress = SilentProgress;
let outcome = push(
request,
PushServices {
credentials: &mut credentials,
progress: &mut progress,
},
)
.expect("push should succeed");
assert_eq!(outcome.commands.len(), 1);
let report = outcome.report.expect("local receive-pack reports status");
assert!(matches!(report.unpack, ReceivePackUnpackStatus::Ok));
assert!(matches!(
report.commands.as_slice(),
[ReceivePackCommandStatus::Ok { name }] if name == "refs/heads/main"
));
let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
assert_eq!(
remote_refs
.read_ref("refs/heads/main")
.expect("remote ref should read"),
Some(RefTarget::Direct(tip))
);
}
#[test]
fn local_push_actions_preserves_exact_old_new_update() {
let local = temp_repo("actions-update-local");
let remote = temp_repo("actions-update-remote");
let base = write_commit(&local, Vec::new(), "base");
let remote_base = write_commit(&remote, Vec::new(), "base");
assert_eq!(remote_base, base);
let tip = write_commit(&local, vec![base], "tip");
set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
let plan = PushActionPlan::from_actions(
vec![PushAction::Update {
dst: "refs/heads/main".into(),
old: base,
new: tip,
}],
default_options(),
);
let outcome = push_local_actions(&local, &remote, &plan).expect("push actions");
assert_eq!(outcome.commands.len(), 1);
assert_eq!(outcome.commands[0].old_id, base);
assert_eq!(outcome.commands[0].new_id, tip);
let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
assert_eq!(
remote_refs
.read_ref("refs/heads/main")
.expect("remote ref should read"),
Some(RefTarget::Direct(tip))
);
}
#[test]
fn local_push_actions_honors_per_command_force() {
let local = temp_repo("actions-command-force-local");
let remote = temp_repo("actions-command-force-remote");
let base = write_commit(&local, Vec::new(), "base");
let remote_base = write_commit(&remote, Vec::new(), "base");
assert_eq!(remote_base, base);
let unrelated = write_commit(&local, Vec::new(), "unrelated");
set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
let unforced = PushActionPlan::from_commands(
vec![PushCommand {
src: Some(unrelated),
dst: "refs/heads/main".into(),
expected_old: Some(base),
force: false,
}],
default_options(),
);
let err = push_local_actions(&local, &remote, &unforced)
.expect_err("non-fast-forward should reject without command force");
assert!(err.to_string().contains("non-fast-forward"));
let forced = PushActionPlan::from_commands(
vec![PushCommand {
src: Some(unrelated),
dst: "refs/heads/main".into(),
expected_old: Some(base),
force: true,
}],
default_options(),
);
let outcome = push_local_actions(&local, &remote, &forced).expect("command force pushes");
assert_eq!(outcome.commands.len(), 1);
let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
assert_eq!(
remote_refs
.read_ref("refs/heads/main")
.expect("remote ref should read"),
Some(RefTarget::Direct(unrelated))
);
}
#[test]
fn local_push_actions_command_force_is_precise_for_non_ff_validation() {
let local = temp_repo("actions-command-force-precise-local");
let remote = temp_repo("actions-command-force-precise-remote");
let base = write_commit(&local, Vec::new(), "base");
let remote_base = write_commit(&remote, Vec::new(), "base");
assert_eq!(remote_base, base);
let forced_unrelated = write_commit(&local, Vec::new(), "forced unrelated");
let unforced_unrelated = write_commit(&local, Vec::new(), "unforced unrelated");
set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
set_ref(&remote, "refs/heads/topic", RefTarget::Direct(base));
let plan = PushActionPlan::from_commands_and_infer_pack_roots(
vec![
PushCommand {
src: Some(forced_unrelated),
dst: "refs/heads/main".into(),
expected_old: Some(base),
force: true,
},
PushCommand {
src: Some(unforced_unrelated),
dst: "refs/heads/topic".into(),
expected_old: Some(base),
force: false,
},
],
default_options(),
);
let err = push_local_actions(&local, &remote, &plan)
.expect_err("only the forced command should bypass non-fast-forward validation");
assert!(err.to_string().contains("non-fast-forward update to topic"));
let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
assert_eq!(
remote_refs
.read_ref("refs/heads/main")
.expect("remote ref should read"),
Some(RefTarget::Direct(base))
);
assert_eq!(
remote_refs
.read_ref("refs/heads/topic")
.expect("remote ref should read"),
Some(RefTarget::Direct(base))
);
}
#[test]
fn local_push_actions_stale_update_old_rejects_without_mutating() {
let local = temp_repo("actions-stale-local");
let remote = temp_repo("actions-stale-remote");
let base = write_commit(&local, Vec::new(), "base");
let remote_base = write_commit(&remote, Vec::new(), "base");
assert_eq!(remote_base, base);
let tip = write_commit(&local, vec![base], "tip");
let concurrent = write_commit(&remote, vec![base], "concurrent");
set_ref(&remote, "refs/heads/main", RefTarget::Direct(concurrent));
let plan = PushActionPlan::from_actions(
vec![PushAction::Update {
dst: "refs/heads/main".into(),
old: base,
new: tip,
}],
default_options(),
);
let err = push_local_actions(&local, &remote, &plan).expect_err("stale old rejects");
assert!(err.to_string().contains("expected ref refs/heads/main"));
let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
assert_eq!(
remote_refs
.read_ref("refs/heads/main")
.expect("remote ref should read"),
Some(RefTarget::Direct(concurrent))
);
}
#[test]
fn local_push_actions_stale_delete_old_rejects_without_mutating() {
let local = temp_repo("actions-delete-local");
let remote = temp_repo("actions-delete-remote");
let base = write_commit(&local, Vec::new(), "base");
let remote_base = write_commit(&remote, Vec::new(), "base");
assert_eq!(remote_base, base);
let concurrent = write_commit(&remote, vec![base], "concurrent");
set_ref(&remote, "refs/heads/main", RefTarget::Direct(concurrent));
let plan = PushActionPlan::from_actions(
vec![PushAction::Delete {
dst: "refs/heads/main".into(),
old: Some(base),
}],
default_options(),
);
let err = push_local_actions(&local, &remote, &plan).expect_err("stale delete rejects");
assert!(err.to_string().contains("expected ref refs/heads/main"));
let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
assert_eq!(
remote_refs
.read_ref("refs/heads/main")
.expect("remote ref should read"),
Some(RefTarget::Direct(concurrent))
);
}
#[test]
fn local_push_actions_create_rejects_existing_ref() {
let local = temp_repo("actions-create-local");
let remote = temp_repo("actions-create-remote");
let base = write_commit(&local, Vec::new(), "base");
let remote_base = write_commit(&remote, Vec::new(), "base");
assert_eq!(remote_base, base);
let tip = write_commit(&local, vec![base], "tip");
set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
let plan = PushActionPlan::from_actions(
vec![PushAction::Create {
dst: "refs/heads/main".into(),
new: tip,
}],
default_options(),
);
let err = push_local_actions(&local, &remote, &plan).expect_err("create must be absent");
assert!(
err.to_string()
.contains("expected ref refs/heads/main to not already exist")
);
let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
assert_eq!(
remote_refs
.read_ref("refs/heads/main")
.expect("remote ref should read"),
Some(RefTarget::Direct(base))
);
}
#[test]
fn report_status_rejection_is_an_error() {
let report = ReceivePackReportStatus {
unpack: ReceivePackUnpackStatus::Ok,
commands: vec![ReceivePackCommandStatus::Ng {
name: "refs/heads/main".into(),
message: "hook declined".into(),
}],
};
let err = validate_receive_pack_report(&report).expect_err("ng report should fail");
assert!(err.to_string().contains("hook declined"));
}
#[test]
fn failed_local_push_does_not_partially_mutate_remote_ref() {
let local = temp_repo("local-rejected");
let remote = temp_repo("remote-rejected");
let base = write_commit(&local, Vec::new(), "base");
let planned = write_commit(&local, vec![base], "planned");
let concurrent = write_commit(&local, vec![base], "concurrent");
set_ref(&local, "refs/heads/main", RefTarget::Direct(planned));
set_ref(
&local,
"HEAD",
RefTarget::Symbolic("refs/heads/main".into()),
);
set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
let destination = PushDestination::Local {
git_dir: remote.clone(),
common_git_dir: remote.clone(),
};
let refspecs = vec!["refs/heads/main:refs/heads/main".to_string()];
let options = default_options();
let request = PushRequest {
git_dir: &local,
common_git_dir: &local,
format: ObjectFormat::Sha1,
config: &GitConfig::default(),
remote: "origin",
destination: &destination,
refspecs: &refspecs,
options: &options,
};
let mut credentials = NoCredentials;
let mut progress = SilentProgress;
let mut services = PushServices {
credentials: &mut credentials,
progress: &mut progress,
};
let plan = plan_push(request, &mut services).expect("push should plan");
set_ref(&remote, "refs/heads/main", RefTarget::Direct(concurrent));
let _err = execute_push_plan(request, &mut services, plan)
.expect_err("stale old id should reject the ref update");
let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
assert_eq!(
remote_refs
.read_ref("refs/heads/main")
.expect("remote ref should read"),
Some(RefTarget::Direct(concurrent))
);
}
}