Skip to main content

sley_remote/
push.rs

1//! Callable push orchestration for HTTP(S) and local (`file://`/path) remotes.
2//!
3//! [`push`] sequences the moved transport plumbing ([`crate::http`],
4//! [`crate::local`]) and the protocol codecs ([`sley_protocol`]) into the full
5//! push flow: it advertises the remote's refs, plans the receive-pack commands
6//! for the requested refspecs, rejects non-fast-forward updates (unless forced),
7//! builds the packfile of the objects the remote is missing, sends the
8//! receive-pack request, and parses the report-status. Everything is taken as
9//! explicit parameters — `git_dir`, `common_git_dir`, the [`ObjectFormat`], the
10//! repository [`GitConfig`], the already-resolved destination, the push refspecs,
11//! a [`PushOptions`], and the seam objects ([`CredentialProvider`],
12//! [`ProgressSink`]) — so it never reads process-global state, parses arguments,
13//! or prints. The structured result ([`PushOutcome`]) carries the executed
14//! receive-pack commands and the remote's report-status for the caller to format
15//! into git's "To <remote>" summary and to drive any set-upstream config write.
16//!
17//! SSH push still lives in the CLI; only HTTP and local move here. The
18//! push-planning helpers are shared (the CLI's SSH path calls the same `pub`
19//! functions) so there is a single implementation.
20
21use std::collections::HashMap;
22#[cfg(feature = "http")]
23use std::io::Read;
24use std::path::{Path, PathBuf};
25
26use sley_config::GitConfig;
27use sley_core::{GitError, ObjectFormat, ObjectId, Result};
28use sley_object::{Commit, ObjectType};
29use sley_odb::{FileObjectDatabase, ObjectReader, collect_reachable_object_ids};
30#[cfg(feature = "http")]
31use sley_protocol::{
32    GitService, ReceivePackFeatures, ReceivePackPushRequestOptions, parse_receive_pack_features,
33    read_receive_pack_report_status, smart_http_rpc_request_content_type,
34    smart_http_rpc_result_content_type,
35};
36use sley_protocol::{
37    PushSourceRef, ReceivePackCommand, ReceivePackCommandStatus, ReceivePackPushRequest,
38    ReceivePackReportStatus, ReceivePackRequest, ReceivePackUnpackStatus, RefAdvertisement,
39    parse_refspec, plan_push_commands,
40};
41
42use crate::pack::push_pack_roots;
43#[cfg(feature = "http")]
44use crate::pack::{PushPackRequest, build_receive_pack_body};
45use sley_refs::{FileRefStore, Ref, RefTarget};
46use sley_transport::RemoteUrl;
47#[cfg(feature = "http")]
48use sley_transport::{HttpClient, http_smart_rpc_url};
49
50use crate::{CredentialProvider, ProgressSink};
51
52/// How a push delivers refs and objects to the remote.
53///
54/// The caller resolves the remote (URL rewriting, `pushurl` selection,
55/// repository discovery — all process-state dependent) and hands `push` a
56/// concrete transport.
57pub enum PushDestination {
58    /// A smart-HTTP(S) remote at the given already-resolved URL.
59    Http(RemoteUrl),
60    /// An SSH remote at the given already-resolved URL. Pushed by spawning `ssh`
61    /// (the credential seam is unused — the `ssh` program owns authentication).
62    Ssh(RemoteUrl),
63    /// A native anonymous `git://` remote at the given already-resolved URL.
64    Git(RemoteUrl),
65    /// A local repository served in-process from `git_dir`.
66    Local {
67        /// The remote repository's `$GIT_DIR`.
68        git_dir: PathBuf,
69        /// The remote repository's common `$GIT_DIR` (object format source).
70        common_git_dir: PathBuf,
71    },
72}
73
74/// Controls for a [`push`] run, mirroring the `git push` flags the CLI parses
75/// that affect the wire/planning behavior the library owns.
76///
77/// `set-upstream` (`-u`) is intentionally absent: it only writes
78/// `branch.<name>.remote`/`merge` config, which is a caller concern (the library
79/// returns the executed commands in [`PushOutcome::commands`] so the caller can
80/// drive that write). Atomic / push-options / thin are likewise absent because
81/// the CLI's HTTP and local push paths accept but do not act on them today; this
82/// stays a faithful refactor of the existing behavior.
83#[derive(Debug, Clone, Copy, Default)]
84pub struct PushOptions {
85    /// Suppress the per-command side-effect of negotiating the `quiet`
86    /// receive-pack capability (matching `git push --quiet`). Output suppression
87    /// itself is a caller concern — the library always returns the outcome.
88    pub quiet: bool,
89    /// Force every update, bypassing the non-fast-forward check. Per-refspec `+`
90    /// forces are honored independently of this flag.
91    pub force: bool,
92}
93
94/// One caller-authored receive-pack command.
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct PushCommand {
97    /// The object id to install at `dst`, or `None` for a delete.
98    pub src: Option<ObjectId>,
99    /// Full destination ref name.
100    pub dst: String,
101    /// The expected remote old object id. `None` lowers to the zero oid, which
102    /// receive-pack treats as create-only for updates and unconditional for
103    /// deletes.
104    pub expected_old: Option<ObjectId>,
105    /// Bypass the non-fast-forward check for this command. This mirrors a
106    /// refspec-local leading `+`; [`PushOptions::force`] still forces every
107    /// command in the plan.
108    pub force: bool,
109}
110
111/// A typed push action that preserves the caller's exact old/new/delete intent.
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub enum PushAction {
114    Create {
115        dst: String,
116        new: ObjectId,
117    },
118    Update {
119        dst: String,
120        old: ObjectId,
121        new: ObjectId,
122    },
123    Delete {
124        dst: String,
125        old: Option<ObjectId>,
126    },
127}
128
129impl From<PushAction> for PushCommand {
130    fn from(value: PushAction) -> Self {
131        match value {
132            PushAction::Create { dst, new } => Self {
133                src: Some(new),
134                dst,
135                expected_old: None,
136                force: false,
137            },
138            PushAction::Update { dst, old, new } => Self {
139                src: Some(new),
140                dst,
141                expected_old: Some(old),
142                force: false,
143            },
144            PushAction::Delete { dst, old } => Self {
145                src: None,
146                dst,
147                expected_old: old,
148                force: false,
149            },
150        }
151    }
152}
153
154/// A caller-authored push plan. This is distinct from [`PushPlan`], which is a
155/// negotiated, executable transport token returned by [`plan_push`].
156#[derive(Debug, Clone)]
157pub struct PushActionPlan {
158    pub commands: Vec<PushCommand>,
159    pub pack_objects: Vec<ObjectId>,
160    pub options: PushOptions,
161}
162
163impl PushActionPlan {
164    pub fn from_actions(actions: Vec<PushAction>, options: PushOptions) -> Self {
165        Self {
166            commands: actions.into_iter().map(PushCommand::from).collect(),
167            pack_objects: Vec::new(),
168            options,
169        }
170    }
171
172    pub fn from_commands(commands: Vec<PushCommand>, options: PushOptions) -> Self {
173        Self {
174            commands,
175            pack_objects: Vec::new(),
176            options,
177        }
178    }
179
180    pub fn from_commands_and_infer_pack_roots(
181        commands: Vec<PushCommand>,
182        options: PushOptions,
183    ) -> Self {
184        let mut pack_objects = Vec::new();
185        for command in &commands {
186            let Some(src) = command.src.as_ref() else {
187                continue;
188            };
189            if !pack_objects.contains(src) {
190                pack_objects.push(*src);
191            }
192        }
193        Self {
194            commands,
195            pack_objects,
196            options,
197        }
198    }
199}
200
201/// The structured result of a [`push`].
202#[derive(Debug, Clone, Default)]
203pub struct PushOutcome {
204    /// The receive-pack commands that were executed, in planning order. Each
205    /// carries the ref name and its old/new object id; the caller formats these
206    /// into git's "To <remote>" summary and uses them to drive set-upstream.
207    /// Empty when nothing matched the refspecs (a no-op push).
208    pub commands: Vec<ReceivePackCommand>,
209    /// The remote's report-status, when one was requested and received (i.e. the
210    /// remote advertised `report-status`). `None` when report-status was not
211    /// negotiated. Already validated: a failed unpack or a rejected ref is
212    /// surfaced as an `Err` from [`push`], not returned here.
213    pub report: Option<ReceivePackReportStatus>,
214}
215
216/// Fully resolved inputs for a [`push`] run.
217#[derive(Clone, Copy)]
218pub struct PushRequest<'a> {
219    /// Local repository `$GIT_DIR`.
220    pub git_dir: &'a Path,
221    /// Local repository common `$GIT_DIR`, used for object access.
222    pub common_git_dir: &'a Path,
223    /// Local repository object format.
224    pub format: ObjectFormat,
225    /// Local repository config snapshot.
226    pub config: &'a GitConfig,
227    /// Remote name or source string, used for diagnostics.
228    pub remote: &'a str,
229    /// Already-resolved push destination.
230    pub destination: &'a PushDestination,
231    /// Refspecs requested by the caller.
232    pub refspecs: &'a [String],
233    /// Push behavior flags.
234    pub options: &'a PushOptions,
235}
236
237/// Fully resolved inputs for a caller-authored exact push plan.
238#[derive(Clone, Copy)]
239pub struct PushActionRequest<'a> {
240    /// Local repository `$GIT_DIR`.
241    pub git_dir: &'a Path,
242    /// Local repository common `$GIT_DIR`, used for object access.
243    pub common_git_dir: &'a Path,
244    /// Local repository object format.
245    pub format: ObjectFormat,
246    /// Local repository config snapshot.
247    pub config: &'a GitConfig,
248    /// Remote name or source string, used for diagnostics.
249    pub remote: &'a str,
250    /// Already-resolved push destination.
251    pub destination: &'a PushDestination,
252    /// Caller-authored exact push plan.
253    pub plan: &'a PushActionPlan,
254}
255
256/// Mutable seams used while pushing.
257pub struct PushServices<'a> {
258    /// Credential source for authenticated transports.
259    pub credentials: &'a mut dyn CredentialProvider,
260    /// Progress sink reserved for future push progress.
261    pub progress: &'a mut dyn ProgressSink,
262}
263
264/// A push after ref negotiation and command planning, but before any ref update
265/// is sent or applied.
266pub struct PushPlan {
267    /// The receive-pack commands that will be executed if the caller proceeds.
268    pub commands: Vec<ReceivePackCommand>,
269    execution: PushExecution,
270}
271
272enum PushExecution {
273    Noop,
274    #[cfg(feature = "http")]
275    Http {
276        remote_url: RemoteUrl,
277        features: ReceivePackFeatures,
278        advertisements: Vec<RefAdvertisement>,
279        pack_objects: Vec<ObjectId>,
280    },
281    Ssh(crate::ssh::SshPushPlan),
282    Git(crate::git::GitPushPlan),
283    Local {
284        remote_git_dir: PathBuf,
285        remote_common_git_dir: PathBuf,
286        remote_refs: Vec<RefAdvertisement>,
287        command_forces: Vec<(ReceivePackCommand, bool)>,
288        pack_objects: Vec<ObjectId>,
289    },
290}
291
292/// Push `refspecs` to a resolved `destination` from the repository at `git_dir`.
293///
294/// Performs the work the CLI's `push_http_repository`/`push_local_repository`
295/// did: advertises the remote's refs, plans the receive-pack commands for
296/// `refspecs`, rejects non-fast-forward branch updates (unless forced), builds
297/// the pack of objects the remote lacks, sends the receive-pack request, parses
298/// and validates the report-status, and returns the executed commands. `remote`
299/// is the remote/argument the caller resolved `destination` from (used only for
300/// error messages here).
301///
302/// Returns the structured [`PushOutcome`]; never prints or returns
303/// `GitError::Exit`. A still-`None` report in the outcome means the remote did
304/// not advertise `report-status`. Set-upstream config and the "To <remote>"
305/// summary are the caller's job, driven from [`PushOutcome::commands`].
306pub fn push(request: PushRequest<'_>, mut services: PushServices<'_>) -> Result<PushOutcome> {
307    let plan = plan_push(request, &mut services)?;
308    execute_push_plan(request, &mut services, plan)
309}
310
311/// Push a caller-authored exact plan, preserving its old/new/delete command ids.
312pub fn push_actions(
313    request: PushActionRequest<'_>,
314    mut services: PushServices<'_>,
315) -> Result<PushOutcome> {
316    let plan = plan_push_actions(request, &mut services)?;
317    execute_push_action_plan(request, &mut services, plan)
318}
319
320/// Negotiate with the remote and compute the receive-pack command list without
321/// sending a pack or applying a ref update.
322pub fn plan_push(request: PushRequest<'_>, services: &mut PushServices<'_>) -> Result<PushPlan> {
323    // `config` and `progress` are part of the seam (mirroring `fetch`) but the
324    // current push flow drives credentials from the caller-built provider and
325    // returns its summary in `PushOutcome` rather than streaming progress, so
326    // neither is consumed yet. Kept named for the public API and future use.
327    let _ = request.config;
328    let _ = &mut services.progress;
329    match request.destination {
330        #[cfg(feature = "http")]
331        PushDestination::Http(remote_url) => plan_push_http(PushHttpRequest {
332            git_dir: request.git_dir,
333            common_git_dir: request.common_git_dir,
334            format: request.format,
335            remote_url,
336            refspecs: request.refspecs,
337            options: request.options,
338            credentials: services.credentials,
339        }),
340        #[cfg(not(feature = "http"))]
341        PushDestination::Http(_) => Err(GitError::Unsupported(
342            "HTTP transport is not enabled in this build".into(),
343        )),
344        PushDestination::Ssh(remote_url) => {
345            let plan = crate::ssh::plan_push_ssh(crate::ssh::SshPushRequest {
346                git_dir: request.git_dir,
347                common_git_dir: request.common_git_dir,
348                format: request.format,
349                remote: remote_url,
350                refspecs: request.refspecs,
351                force: request.options.force,
352            })?;
353            let commands = plan.commands.clone();
354            let execution = if commands.is_empty() {
355                PushExecution::Noop
356            } else {
357                PushExecution::Ssh(plan)
358            };
359            Ok(PushPlan {
360                commands,
361                execution,
362            })
363        }
364        PushDestination::Git(remote_url) => {
365            let plan = crate::git::plan_push_git(crate::git::GitPushRequest {
366                git_dir: request.git_dir,
367                common_git_dir: request.common_git_dir,
368                format: request.format,
369                remote: remote_url,
370                refspecs: request.refspecs,
371                force: request.options.force,
372            })?;
373            let commands = plan.commands.clone();
374            let execution = if commands.is_empty() {
375                PushExecution::Noop
376            } else {
377                PushExecution::Git(plan)
378            };
379            Ok(PushPlan {
380                commands,
381                execution,
382            })
383        }
384        PushDestination::Local {
385            git_dir: remote_git_dir,
386            common_git_dir: remote_common_git_dir,
387        } => plan_push_local(PushLocalRequest {
388            git_dir: request.git_dir,
389            common_git_dir: request.common_git_dir,
390            format: request.format,
391            remote: request.remote,
392            remote_git_dir,
393            remote_common_git_dir,
394            refspecs: request.refspecs,
395            options: request.options,
396        }),
397    }
398}
399
400/// Negotiate with the remote and bind a caller-authored exact push plan to a
401/// transport execution token.
402pub fn plan_push_actions(
403    request: PushActionRequest<'_>,
404    services: &mut PushServices<'_>,
405) -> Result<PushPlan> {
406    let _ = request.config;
407    let _ = &mut services.progress;
408    let commands = receive_pack_commands_from_action_plan(request.format, request.plan)?;
409    let command_forces = commands
410        .iter()
411        .cloned()
412        .zip(request.plan.commands.iter())
413        .map(|(command, planned)| (command, request.plan.options.force || planned.force))
414        .collect::<Vec<_>>();
415    match request.destination {
416        #[cfg(feature = "http")]
417        PushDestination::Http(remote_url) => {
418            let client = crate::http::new_http_client();
419            let discovered = crate::http::http_service_advertisements(
420                &client,
421                remote_url,
422                request.format,
423                GitService::ReceivePack,
424                services.credentials,
425            )?;
426            let advertisement_set = discovered.set;
427            let features = advertised_receive_pack_features(&advertisement_set.refs)?;
428            verify_remote_object_format(&features, request.format)?;
429            let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, request.format);
430            reject_non_fast_forward_pushes(&local_db, request.format, &command_forces)?;
431            let execution = if commands.is_empty() {
432                PushExecution::Noop
433            } else {
434                PushExecution::Http {
435                    remote_url: remote_url.clone(),
436                    features,
437                    advertisements: advertisement_set.refs,
438                    pack_objects: request.plan.pack_objects.clone(),
439                }
440            };
441            Ok(PushPlan {
442                commands,
443                execution,
444            })
445        }
446        #[cfg(not(feature = "http"))]
447        PushDestination::Http(_) => Err(GitError::Unsupported(
448            "HTTP transport is not enabled in this build".into(),
449        )),
450        PushDestination::Ssh(remote_url) => {
451            let plan = crate::ssh::plan_push_ssh_commands(crate::ssh::SshPushCommandsRequest {
452                common_git_dir: request.common_git_dir,
453                format: request.format,
454                remote: remote_url,
455                command_forces: command_forces.clone(),
456                pack_objects: request.plan.pack_objects.clone(),
457            })?;
458            let commands = plan.commands.clone();
459            let execution = if commands.is_empty() {
460                PushExecution::Noop
461            } else {
462                PushExecution::Ssh(plan)
463            };
464            Ok(PushPlan {
465                commands,
466                execution,
467            })
468        }
469        PushDestination::Git(remote_url) => {
470            let plan = crate::git::plan_push_git_commands(crate::git::GitPushCommandsRequest {
471                common_git_dir: request.common_git_dir,
472                format: request.format,
473                remote: remote_url,
474                command_forces: command_forces.clone(),
475                pack_objects: request.plan.pack_objects.clone(),
476            })?;
477            let commands = plan.commands.clone();
478            let execution = if commands.is_empty() {
479                PushExecution::Noop
480            } else {
481                PushExecution::Git(plan)
482            };
483            Ok(PushPlan {
484                commands,
485                execution,
486            })
487        }
488        PushDestination::Local {
489            git_dir: remote_git_dir,
490            common_git_dir: remote_common_git_dir,
491        } => {
492            let remote_format = crate::object_format_for_git_dir(remote_common_git_dir)?;
493            if remote_format != request.format {
494                return Err(GitError::InvalidObjectId(format!(
495                    "remote repository uses {}, local repository uses {}",
496                    remote_format.name(),
497                    request.format.name()
498                )));
499            }
500            let remote_refs =
501                crate::local::local_fetch_advertisements(remote_git_dir, request.format)?;
502            let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, request.format);
503            reject_non_fast_forward_pushes(&local_db, request.format, &command_forces)?;
504            let execution = if commands.is_empty() {
505                PushExecution::Noop
506            } else {
507                PushExecution::Local {
508                    remote_git_dir: remote_git_dir.to_path_buf(),
509                    remote_common_git_dir: remote_common_git_dir.to_path_buf(),
510                    remote_refs,
511                    command_forces,
512                    pack_objects: request.plan.pack_objects.clone(),
513                }
514            };
515            Ok(PushPlan {
516                commands,
517                execution,
518            })
519        }
520    }
521}
522
523/// Execute a previously planned push.
524pub fn execute_push_plan(
525    request: PushRequest<'_>,
526    services: &mut PushServices<'_>,
527    plan: PushPlan,
528) -> Result<PushOutcome> {
529    let _ = (request.config, request.remote);
530    let _ = &mut services.progress;
531    if plan.commands.is_empty() {
532        return Ok(PushOutcome::default());
533    }
534    match plan.execution {
535        PushExecution::Noop => Ok(PushOutcome::default()),
536        #[cfg(feature = "http")]
537        PushExecution::Http {
538            remote_url,
539            features,
540            advertisements,
541            pack_objects,
542        } => execute_push_http(
543            request,
544            services.credentials,
545            plan.commands,
546            remote_url,
547            features,
548            advertisements,
549            pack_objects,
550        ),
551        PushExecution::Ssh(plan) => crate::ssh::execute_push_ssh_plan(request, plan),
552        PushExecution::Git(plan) => crate::git::execute_push_git_plan(request, plan),
553        PushExecution::Local {
554            remote_git_dir,
555            remote_common_git_dir,
556            remote_refs,
557            command_forces,
558            pack_objects,
559        } => execute_push_local(
560            request,
561            plan.commands,
562            remote_git_dir,
563            remote_common_git_dir,
564            remote_refs,
565            command_forces,
566            pack_objects,
567        ),
568    }
569}
570
571/// Execute a previously negotiated exact push plan.
572pub fn execute_push_action_plan(
573    request: PushActionRequest<'_>,
574    services: &mut PushServices<'_>,
575    plan: PushPlan,
576) -> Result<PushOutcome> {
577    let refspecs: &[String] = &[];
578    execute_push_plan(
579        PushRequest {
580            git_dir: request.git_dir,
581            common_git_dir: request.common_git_dir,
582            format: request.format,
583            config: request.config,
584            remote: request.remote,
585            destination: request.destination,
586            refspecs,
587            options: &request.plan.options,
588        },
589        services,
590        plan,
591    )
592}
593
594/// Push to a smart-HTTP(S) remote: advertise via receive-pack info/refs, plan,
595/// build the pack, POST the receive-pack RPC, and validate the report-status.
596#[cfg(feature = "http")]
597struct PushHttpRequest<'a> {
598    git_dir: &'a Path,
599    common_git_dir: &'a Path,
600    format: ObjectFormat,
601    remote_url: &'a RemoteUrl,
602    refspecs: &'a [String],
603    options: &'a PushOptions,
604    credentials: &'a mut dyn CredentialProvider,
605}
606
607#[cfg(feature = "http")]
608fn plan_push_http(request: PushHttpRequest<'_>) -> Result<PushPlan> {
609    let PushHttpRequest {
610        git_dir,
611        common_git_dir,
612        format,
613        remote_url,
614        refspecs,
615        options,
616        credentials,
617    } = request;
618    let client = crate::http::new_http_client();
619    let discovered = crate::http::http_service_advertisements(
620        &client,
621        remote_url,
622        format,
623        GitService::ReceivePack,
624        credentials,
625    )?;
626    let advertisement_set = discovered.set;
627    let features = advertised_receive_pack_features(&advertisement_set.refs)?;
628    verify_remote_object_format(&features, format)?;
629
630    let local_store = FileRefStore::new(git_dir, format);
631    let mut local_refs = local_push_source_refs(&local_store, format)?;
632    add_revision_push_sources(git_dir, format, refspecs, &mut local_refs);
633    let command_forces = plan_push_command_forces(
634        format,
635        &local_refs,
636        &advertisement_set.refs,
637        refspecs,
638        options.force,
639    )?;
640    let local_db = FileObjectDatabase::from_git_dir(common_git_dir, format);
641    reject_non_fast_forward_pushes(&local_db, format, &command_forces)?;
642    let commands = commands_from_forces(&command_forces);
643    let execution = if commands.is_empty() {
644        PushExecution::Noop
645    } else {
646        PushExecution::Http {
647            remote_url: remote_url.clone(),
648            features,
649            advertisements: advertisement_set.refs,
650            pack_objects: Vec::new(),
651        }
652    };
653    Ok(PushPlan {
654        commands,
655        execution,
656    })
657}
658
659#[cfg(feature = "http")]
660fn execute_push_http(
661    request: PushRequest<'_>,
662    credentials: &mut dyn CredentialProvider,
663    commands: Vec<ReceivePackCommand>,
664    remote_url: RemoteUrl,
665    features: ReceivePackFeatures,
666    advertisements: Vec<RefAdvertisement>,
667    pack_objects: Vec<ObjectId>,
668) -> Result<PushOutcome> {
669    let client = crate::http::new_http_client();
670    let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, request.format);
671    let body = build_receive_pack_body(&PushPackRequest {
672        local_db: &local_db,
673        format: request.format,
674        commands: &commands,
675        pack_objects: &pack_objects,
676        remote_advertisements: &advertisements,
677        features: &features,
678        options: receive_pack_push_options(&features, request.format, request.options.quiet),
679        thin: false,
680    })?;
681    let url = http_smart_rpc_url(&remote_url, GitService::ReceivePack)?;
682    let content_type = smart_http_rpc_request_content_type(GitService::ReceivePack)?;
683    let mut response = crate::http::http_send_with_auth(&remote_url, credentials, |auth| {
684        client.post(
685            &url,
686            &content_type,
687            &crate::http::http_authorization_headers(auth),
688            &body,
689        )
690    })?;
691    crate::http::http_check_status(&response, &url)?;
692    crate::http::http_validate_content_type(
693        &response,
694        &smart_http_rpc_result_content_type(GitService::ReceivePack)?,
695    )?;
696
697    let report = if features.report_status {
698        let report = read_receive_pack_report_status(&mut response.body)?;
699        validate_receive_pack_report(&report)?;
700        Some(report)
701    } else {
702        let mut sink = Vec::new();
703        response.body.read_to_end(&mut sink)?;
704        None
705    };
706    Ok(PushOutcome { commands, report })
707}
708
709/// Push to a local repository served in-process: advertise from the remote
710/// `git_dir`, plan, build the pack against the remote's reachable objects, and
711/// apply the receive-pack request directly.
712struct PushLocalRequest<'a> {
713    git_dir: &'a Path,
714    common_git_dir: &'a Path,
715    format: ObjectFormat,
716    remote: &'a str,
717    remote_git_dir: &'a Path,
718    remote_common_git_dir: &'a Path,
719    refspecs: &'a [String],
720    options: &'a PushOptions,
721}
722
723fn plan_push_local(request: PushLocalRequest<'_>) -> Result<PushPlan> {
724    let PushLocalRequest {
725        git_dir,
726        common_git_dir,
727        format,
728        remote,
729        remote_git_dir,
730        remote_common_git_dir,
731        refspecs,
732        options,
733    } = request;
734    let _ = remote;
735    let remote_format = crate::object_format_for_git_dir(remote_common_git_dir)?;
736    if remote_format != format {
737        return Err(GitError::InvalidObjectId(format!(
738            "remote repository uses {}, local repository uses {}",
739            remote_format.name(),
740            format.name()
741        )));
742    }
743
744    let local_store = FileRefStore::new(git_dir, format);
745    let mut local_refs = local_push_source_refs(&local_store, format)?;
746    add_revision_push_sources(git_dir, format, refspecs, &mut local_refs);
747    let remote_refs = crate::local::local_fetch_advertisements(remote_git_dir, format)?;
748    let command_forces =
749        plan_push_command_forces(format, &local_refs, &remote_refs, refspecs, options.force)?;
750    let local_db = FileObjectDatabase::from_git_dir(common_git_dir, format);
751    reject_non_fast_forward_pushes(&local_db, format, &command_forces)?;
752    let commands = commands_from_forces(&command_forces);
753    let execution = if commands.is_empty() {
754        PushExecution::Noop
755    } else {
756        PushExecution::Local {
757            remote_git_dir: remote_git_dir.to_path_buf(),
758            remote_common_git_dir: remote_common_git_dir.to_path_buf(),
759            remote_refs,
760            command_forces,
761            pack_objects: Vec::new(),
762        }
763    };
764    Ok(PushPlan {
765        commands,
766        execution,
767    })
768}
769
770fn execute_push_local(
771    request: PushRequest<'_>,
772    commands: Vec<ReceivePackCommand>,
773    remote_git_dir: PathBuf,
774    remote_common_git_dir: PathBuf,
775    remote_refs: Vec<RefAdvertisement>,
776    _command_forces: Vec<(ReceivePackCommand, bool)>,
777    pack_objects: Vec<ObjectId>,
778) -> Result<PushOutcome> {
779    let remote_excluded_tips = remote_refs
780        .iter()
781        .map(|reference| reference.oid)
782        .collect::<Vec<_>>();
783    let starts = push_pack_roots(&commands, &pack_objects);
784    let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, request.format);
785    let remote_db = FileObjectDatabase::from_git_dir(&remote_common_git_dir, request.format);
786    let remote_excluded =
787        collect_reachable_object_ids(&remote_db, request.format, remote_excluded_tips)?;
788    let packfile = if starts.is_empty() {
789        Vec::new()
790    } else {
791        b"PACK".to_vec()
792    };
793    let receive_request = ReceivePackPushRequest {
794        commands: ReceivePackRequest {
795            shallow: Vec::new(),
796            commands: commands.clone(),
797            capabilities: Vec::new(),
798        },
799        push_options: None,
800        packfile,
801    };
802    let report = crate::local::receive_pack_reachable_pack_into_local_repository(
803        &remote_git_dir,
804        request.format,
805        &receive_request,
806        &local_db,
807        starts,
808        remote_excluded,
809    )?;
810    validate_receive_pack_report(&report)?;
811    Ok(PushOutcome {
812        commands,
813        report: Some(report),
814    })
815}
816
817/// Parse the receive-pack features from the leading ref advertisement (the empty
818/// default when the remote advertised no refs).
819#[cfg(feature = "http")]
820fn advertised_receive_pack_features(
821    advertisements: &[RefAdvertisement],
822) -> Result<ReceivePackFeatures> {
823    advertisements
824        .first()
825        .map(|advertisement| parse_receive_pack_features(&advertisement.capabilities))
826        .transpose()
827        .map(Option::unwrap_or_default)
828}
829
830/// Reject a push whose object format disagrees with the remote's advertised
831/// `object-format`, and require the advertisement for any non-SHA-1 push.
832#[cfg(feature = "http")]
833fn verify_remote_object_format(features: &ReceivePackFeatures, format: ObjectFormat) -> Result<()> {
834    if let Some(remote_format) = features.object_format {
835        if remote_format != format {
836            return Err(GitError::InvalidObjectId(format!(
837                "remote repository uses {}, local repository uses {}",
838                remote_format.name(),
839                format.name()
840            )));
841        }
842    } else if format != ObjectFormat::Sha1 {
843        return Err(GitError::InvalidObjectId(format!(
844            "remote repository did not advertise object-format for {} push",
845            format.name()
846        )));
847    }
848    Ok(())
849}
850
851/// The receive-pack push-request options for the negotiated `features`, matching
852/// git: report-status when advertised, ofs-delta when advertised, `quiet` only
853/// when both requested and advertised, and the advertised object-format only when
854/// the local repository's `format` is not SHA-1.
855#[cfg(feature = "http")]
856fn receive_pack_push_options(
857    features: &ReceivePackFeatures,
858    format: ObjectFormat,
859    quiet: bool,
860) -> ReceivePackPushRequestOptions {
861    ReceivePackPushRequestOptions {
862        report_status: features.report_status,
863        ofs_delta: features.ofs_delta,
864        quiet: quiet && features.quiet,
865        object_format: features
866            .object_format
867            .filter(|_| format != ObjectFormat::Sha1),
868        ..ReceivePackPushRequestOptions::default()
869    }
870}
871
872/// Plan the receive-pack commands for `refspecs`, pairing each with whether it is
873/// forced (the global `force` flag or the refspec's own `+`). Each refspec is
874/// normalized then planned independently so per-refspec force is preserved,
875/// matching the CLI.
876fn plan_push_command_forces(
877    format: ObjectFormat,
878    local_refs: &[PushSourceRef],
879    remote_refs: &[RefAdvertisement],
880    refspecs: &[String],
881    force: bool,
882) -> Result<Vec<(ReceivePackCommand, bool)>> {
883    let parsed_refspecs = refspecs
884        .iter()
885        .map(|refspec| {
886            let normalized =
887                normalize_push_refspec_for_sources(refspec, local_refs, remote_refs)?;
888            parse_refspec(&normalized)
889        })
890        .collect::<Result<Vec<_>>>()?;
891    let mut command_forces = Vec::new();
892    for refspec in &parsed_refspecs {
893        for command in plan_push_commands(
894            format,
895            local_refs,
896            remote_refs,
897            std::slice::from_ref(refspec),
898        )? {
899            command_forces.push((command, force || refspec.force));
900        }
901    }
902    Ok(command_forces)
903}
904
905fn add_revision_push_sources(
906    git_dir: &Path,
907    format: ObjectFormat,
908    refspecs: &[String],
909    local_refs: &mut Vec<PushSourceRef>,
910) {
911    for refspec in refspecs {
912        let refspec = refspec.strip_prefix('+').unwrap_or(refspec);
913        let src = refspec.split_once(':').map_or(refspec, |(src, _)| src);
914        if src.is_empty() || src == "HEAD" || src.starts_with("refs/") {
915            continue;
916        }
917        if local_refs.iter().any(|reference| {
918            reference.name == src
919                || reference.name == format!("refs/heads/{src}")
920                || reference.name == format!("refs/tags/{src}")
921        }) {
922            continue;
923        }
924        if let Ok(oid) = sley_rev::resolve_revision(git_dir, format, src)
925            && !local_refs.iter().any(|reference| reference.name == src)
926        {
927            local_refs.push(PushSourceRef {
928                name: src.to_string(),
929                oid,
930            });
931        }
932    }
933}
934
935fn normalize_push_refspec_for_sources(
936    refspec: &str,
937    local_refs: &[PushSourceRef],
938    remote_refs: &[RefAdvertisement],
939) -> Result<String> {
940    let (force, refspec) = refspec
941        .strip_prefix('+')
942        .map_or((false, refspec), |refspec| (true, refspec));
943    let normalized = if let Some((src, dst)) = refspec.split_once(':') {
944        let (src, src_kind) = normalize_push_source_refname(src, local_refs);
945        let dst = normalize_push_destination_refname(dst, src_kind, remote_refs)?;
946        format!("{src}:{dst}")
947    } else {
948        let (name, _) = normalize_push_source_refname(refspec, local_refs);
949        // A colon-less refspec re-uses the source's *resolved* full name as the
950        // implicit destination (git's `match_explicit`: a NULL dst resolves to
951        // the matched source ref). That full name is then disambiguated against
952        // the remote's existing refs, so `git push <remote> frotz` (a tag)
953        // lands on `refs/tags/frotz` even when the remote also has a same-named
954        // branch.
955        let dst = match count_refspec_match_dst(&name, remote_refs) {
956            DstMatch::Unique(matched) => matched.to_string(),
957            DstMatch::None => name.clone(),
958            DstMatch::Ambiguous => {
959                return Err(GitError::Command(format!(
960                    "dst refspec {name} matches more than one"
961                )));
962            }
963        };
964        format!("{name}:{dst}")
965    };
966    Ok(if force {
967        format!("+{normalized}")
968    } else {
969        normalized
970    })
971}
972
973/// git's `refname_match`: true when `full_name` equals `abbrev` expanded by one
974/// of the `ref_rev_parse_rules`. Returns the matched rule's rank (higher = more
975/// specific) so the caller can replicate git's strong/weak distinction.
976fn refname_match_rank(abbrev: &str, full_name: &str) -> Option<usize> {
977    const RULES: [&str; 6] = [
978        "{}",
979        "refs/{}",
980        "refs/tags/{}",
981        "refs/heads/{}",
982        "refs/remotes/{}",
983        "refs/remotes/{}/HEAD",
984    ];
985    for (idx, rule) in RULES.iter().enumerate() {
986        let (prefix, suffix) = rule.split_once("{}").unwrap_or((rule, ""));
987        if full_name == format!("{prefix}{abbrev}{suffix}") {
988            return Some(RULES.len() - idx);
989        }
990    }
991    None
992}
993
994/// The outcome of git's `count_refspec_match` for a push destination.
995enum DstMatch<'a> {
996    /// Exactly one acceptable match (one strong, or zero strong + one weak).
997    Unique(&'a str),
998    /// No remote ref matched — the caller should `guess_ref` or use the literal.
999    None,
1000    /// More than one match — git dies with "dst refspec … matches more than one".
1001    Ambiguous,
1002}
1003
1004/// git's `count_refspec_match` for a push destination: find the unique existing
1005/// remote ref that `pattern` resolves to, distinguishing strong matches (full
1006/// name, top-level, or a head/tag) from weak ones (a partial match outside
1007/// heads/tags, e.g. `origin/main` → `refs/remotes/origin/main`). One strong
1008/// match wins outright; with no strong match a single weak match is used; more
1009/// than one acceptable match is ambiguous.
1010fn count_refspec_match_dst<'a>(pattern: &str, remote_refs: &'a [RefAdvertisement]) -> DstMatch<'a> {
1011    let patlen = pattern.len();
1012    let mut strong: Option<&str> = None;
1013    let mut strong_count = 0usize;
1014    let mut weak: Option<&str> = None;
1015    let mut weak_count = 0usize;
1016    for advert in remote_refs {
1017        let name = advert.name.as_str();
1018        if refname_match_rank(pattern, name).is_none() {
1019            continue;
1020        }
1021        let namelen = name.len();
1022        let is_weak = namelen != patlen
1023            && patlen + 5 != namelen
1024            && !name.starts_with("refs/heads/")
1025            && !name.starts_with("refs/tags/");
1026        if is_weak {
1027            weak = Some(name);
1028            weak_count += 1;
1029        } else {
1030            strong = Some(name);
1031            strong_count += 1;
1032        }
1033    }
1034    match (strong_count, weak_count, strong, weak) {
1035        (1, _, Some(matched), _) => DstMatch::Unique(matched),
1036        (0, 1, _, Some(matched)) => DstMatch::Unique(matched),
1037        (0, 0, _, _) => DstMatch::None,
1038        _ => DstMatch::Ambiguous,
1039    }
1040}
1041
1042#[derive(Clone, Copy)]
1043enum PushSourceKind {
1044    Branch,
1045    Tag,
1046    Other,
1047}
1048
1049fn normalize_push_source_refname(
1050    name: &str,
1051    local_refs: &[PushSourceRef],
1052) -> (String, PushSourceKind) {
1053    if name.is_empty() || name == "HEAD" || name.starts_with("refs/") {
1054        return (name.to_string(), PushSourceKind::Other);
1055    }
1056    let branch = format!("refs/heads/{name}");
1057    let tag = format!("refs/tags/{name}");
1058    let has_branch = local_refs.iter().any(|reference| reference.name == branch);
1059    let has_tag = local_refs.iter().any(|reference| reference.name == tag);
1060    if has_tag && !has_branch {
1061        (tag, PushSourceKind::Tag)
1062    } else if has_branch {
1063        (branch, PushSourceKind::Branch)
1064    } else if local_refs.iter().any(|reference| reference.name == name) {
1065        (name.to_string(), PushSourceKind::Other)
1066    } else {
1067        (branch, PushSourceKind::Branch)
1068    }
1069}
1070
1071fn normalize_push_destination_refname(
1072    name: &str,
1073    src_kind: PushSourceKind,
1074    remote_refs: &[RefAdvertisement],
1075) -> Result<String> {
1076    if name.is_empty() || name == "HEAD" || name.starts_with("refs/") {
1077        return Ok(name.to_string());
1078    }
1079    // git's `match_explicit`: a partial destination first resolves against the
1080    // remote's existing refs (so `main:origin/main` lands on the existing
1081    // `refs/remotes/origin/main`); an ambiguous match is fatal; only when
1082    // nothing matches does it fall back to `guess_ref`'s heads/tags choice
1083    // driven by the source ref's kind.
1084    match count_refspec_match_dst(name, remote_refs) {
1085        DstMatch::Unique(matched) => Ok(matched.to_string()),
1086        DstMatch::Ambiguous => Err(GitError::Command(format!(
1087            "dst refspec {name} matches more than one"
1088        ))),
1089        DstMatch::None => Ok(match src_kind {
1090            PushSourceKind::Tag => format!("refs/tags/{name}"),
1091            PushSourceKind::Branch | PushSourceKind::Other => format!("refs/heads/{name}"),
1092        }),
1093    }
1094}
1095
1096/// The planned commands, dropping the per-command force flags.
1097fn commands_from_forces(command_forces: &[(ReceivePackCommand, bool)]) -> Vec<ReceivePackCommand> {
1098    command_forces
1099        .iter()
1100        .map(|(command, _)| command.clone())
1101        .collect()
1102}
1103
1104fn receive_pack_commands_from_action_plan(
1105    format: ObjectFormat,
1106    plan: &PushActionPlan,
1107) -> Result<Vec<ReceivePackCommand>> {
1108    let zero = ObjectId::null(format);
1109    for oid in &plan.pack_objects {
1110        if oid.format() != format {
1111            return Err(GitError::InvalidObjectId(format!(
1112                "push pack object {oid} has {} object id for {} repository",
1113                oid.format().name(),
1114                format.name()
1115            )));
1116        }
1117    }
1118    plan.commands
1119        .iter()
1120        .map(|command| {
1121            let old_id = command.expected_old.unwrap_or(zero);
1122            let new_id = command.src.unwrap_or(zero);
1123            if old_id.format() != format {
1124                return Err(GitError::InvalidObjectId(format!(
1125                    "push command {} expected old has {} object id for {} repository",
1126                    command.dst,
1127                    old_id.format().name(),
1128                    format.name()
1129                )));
1130            }
1131            if new_id.format() != format {
1132                return Err(GitError::InvalidObjectId(format!(
1133                    "push command {} new id has {} object id for {} repository",
1134                    command.dst,
1135                    new_id.format().name(),
1136                    format.name()
1137                )));
1138            }
1139            Ok(ReceivePackCommand {
1140                old_id,
1141                new_id,
1142                name: command.dst.clone(),
1143            })
1144        })
1145        .collect()
1146}
1147
1148/// Validate a receive-pack report-status, surfacing a failed unpack or any
1149/// rejected ref as an error (matching git's exit-failure message form).
1150pub fn validate_receive_pack_report(report: &ReceivePackReportStatus) -> Result<()> {
1151    if let ReceivePackUnpackStatus::Error(message) = &report.unpack {
1152        return Err(GitError::Command(format!(
1153            "failed to push some refs: unpack failed: {message}"
1154        )));
1155    }
1156    for status in &report.commands {
1157        if let ReceivePackCommandStatus::Ng { name, message } = status {
1158            return Err(GitError::Command(format!(
1159                "failed to push {name}: {message}"
1160            )));
1161        }
1162    }
1163    Ok(())
1164}
1165
1166/// The push-source refs a local repository can match refspecs against: every ref
1167/// resolved to its object id, plus the short `refs/heads/`*and `refs/tags/`*
1168/// aliases, plus `HEAD`. Errors if any ref's object id does not match `format`.
1169pub fn local_push_source_refs(
1170    store: &FileRefStore,
1171    format: ObjectFormat,
1172) -> Result<Vec<PushSourceRef>> {
1173    let mut refs = Vec::new();
1174    for reference in store.list_refs()? {
1175        let Some((oid, _)) = resolve_for_each_ref_target(store, &reference)? else {
1176            continue;
1177        };
1178        if oid.format() != format {
1179            return Err(GitError::InvalidObjectId(format!(
1180                "local ref {} has {} object id for {} repository",
1181                reference.name,
1182                oid.format().name(),
1183                format.name()
1184            )));
1185        }
1186        refs.push(PushSourceRef {
1187            name: reference.name.clone(),
1188            oid,
1189        });
1190        if let Some(short) = reference.name.strip_prefix("refs/heads/") {
1191            refs.push(PushSourceRef {
1192                name: short.to_string(),
1193                oid,
1194            });
1195        }
1196        if let Some(short) = reference.name.strip_prefix("refs/tags/") {
1197            refs.push(PushSourceRef {
1198                name: short.to_string(),
1199                oid,
1200            });
1201        }
1202    }
1203    if let Some(target) = store.read_ref("HEAD")? {
1204        let head = Ref {
1205            name: "HEAD".to_string(),
1206            target,
1207        };
1208        if let Some((oid, _)) = resolve_for_each_ref_target(store, &head)?
1209            && oid.format() == format
1210        {
1211            refs.push(PushSourceRef {
1212                name: "HEAD".to_string(),
1213                oid,
1214            });
1215        }
1216    }
1217    Ok(refs)
1218}
1219
1220/// Normalize a push refspec, expanding short names to `refs/heads/<name>` on both
1221/// sides and supplying the source as the destination when none is given, while
1222/// preserving a leading `+` force marker.
1223pub fn normalize_push_refspec(refspec: &str) -> String {
1224    let (force, refspec) = refspec
1225        .strip_prefix('+')
1226        .map_or((false, refspec), |refspec| (true, refspec));
1227    let normalized = if let Some((src, dst)) = refspec.split_once(':') {
1228        let src = normalize_push_refname(src);
1229        let dst = normalize_push_refname(dst);
1230        format!("{src}:{dst}")
1231    } else {
1232        let name = normalize_push_refname(refspec);
1233        format!("{name}:{name}")
1234    };
1235    if force {
1236        format!("+{normalized}")
1237    } else {
1238        normalized
1239    }
1240}
1241
1242/// Expand a short push ref name to `refs/heads/<name>`, leaving empty names,
1243/// `HEAD`, and already-qualified `refs/`* names untouched.
1244pub fn normalize_push_refname(name: &str) -> String {
1245    if name.is_empty() || name == "HEAD" || name.starts_with("refs/") {
1246        name.to_string()
1247    } else {
1248        format!("refs/heads/{name}")
1249    }
1250}
1251
1252/// Reject any non-forced branch update whose old tip is not an ancestor of the
1253/// new tip (a non-fast-forward). Forced updates, non-branch refs, and
1254/// creations/deletions are skipped.
1255pub fn reject_non_fast_forward_pushes(
1256    local_db: &FileObjectDatabase,
1257    format: ObjectFormat,
1258    command_forces: &[(ReceivePackCommand, bool)],
1259) -> Result<()> {
1260    for (command, force) in command_forces {
1261        if *force
1262            || !command.name.starts_with("refs/heads/")
1263            || command.old_id.is_null()
1264            || command.new_id.is_null()
1265        {
1266            continue;
1267        }
1268        let ancestors = ancestor_depths(local_db, format, &command.new_id)?;
1269        if !ancestors.contains_key(&command.old_id) {
1270            let short = command.name.trim_start_matches("refs/heads/");
1271            return Err(GitError::Command(format!(
1272                "failed to push some refs: non-fast-forward update to {short}"
1273            )));
1274        }
1275    }
1276    Ok(())
1277}
1278
1279/// The depth of every commit reachable from `start` (a breadth-first ancestry
1280/// walk). Used to test fast-forwardness: `start`'s ancestors include `start`
1281/// itself at depth zero. Errors if a reachable object is not a commit.
1282fn ancestor_depths(
1283    db: &FileObjectDatabase,
1284    format: ObjectFormat,
1285    start: &ObjectId,
1286) -> Result<HashMap<ObjectId, usize>> {
1287    let mut depths = HashMap::new();
1288    let mut pending = std::collections::VecDeque::from([(start.clone(), 0usize)]);
1289    while let Some((oid, depth)) = pending.pop_front() {
1290        if depths.get(&oid).is_some_and(|existing| *existing <= depth) {
1291            continue;
1292        }
1293        depths.insert(oid, depth);
1294        let object = db.read_object(&oid)?;
1295        if object.object_type != ObjectType::Commit {
1296            return Err(GitError::InvalidObject(format!(
1297                "expected commit {oid}, found {}",
1298                object.object_type.as_str()
1299            )));
1300        }
1301        let commit = Commit::parse_ref(format, &object.body)?;
1302        for parent in commit.parents {
1303            pending.push_back((parent, depth + 1));
1304        }
1305    }
1306    Ok(depths)
1307}
1308
1309/// Resolve a (possibly symbolic) ref target to its object id, following up to
1310/// five levels of symbolic indirection, returning the first symbolic name seen.
1311fn resolve_for_each_ref_target(
1312    store: &FileRefStore,
1313    reference: &Ref,
1314) -> Result<Option<(ObjectId, Option<String>)>> {
1315    let mut target = reference.target.clone();
1316    let mut symref = None;
1317    for _ in 0..5 {
1318        match target {
1319            RefTarget::Direct(oid) => return Ok(Some((oid, symref))),
1320            RefTarget::Symbolic(name) => {
1321                symref.get_or_insert_with(|| name.clone());
1322                let Some(next) = store.read_ref(&name)? else {
1323                    return Ok(None);
1324                };
1325                target = next;
1326            }
1327        }
1328    }
1329    Ok(None)
1330}
1331
1332#[cfg(test)]
1333mod tests {
1334    use super::*;
1335    use std::fs;
1336    use std::sync::atomic::{AtomicU64, Ordering};
1337
1338    use sley_formats::RepositoryLayout;
1339    use sley_object::{Commit, EncodedObject, ObjectType, Tree};
1340    use sley_odb::{FileObjectDatabase, ObjectWriter};
1341    use sley_protocol::{ReceivePackCommandStatus, ReceivePackUnpackStatus};
1342    use sley_refs::{RefTarget, RefUpdate};
1343
1344    use crate::{NoCredentials, SilentProgress};
1345
1346    static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
1347
1348    fn temp_repo(name: &str) -> PathBuf {
1349        let dir = std::env::temp_dir().join(format!(
1350            "sley-remote-push-{name}-{}-{}",
1351            std::process::id(),
1352            TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
1353        ));
1354        let _ = fs::remove_dir_all(&dir);
1355        RepositoryLayout::init_at(&dir, ObjectFormat::Sha1, false)
1356            .expect("test repository should initialize");
1357        dir.join(".git")
1358    }
1359
1360    fn write_commit(git_dir: &Path, parents: Vec<ObjectId>, message: &str) -> ObjectId {
1361        let format = ObjectFormat::Sha1;
1362        let db = FileObjectDatabase::from_git_dir(git_dir, format);
1363        let tree = db
1364            .write_object(EncodedObject::new(
1365                ObjectType::Tree,
1366                Tree { entries: vec![] }.write(),
1367            ))
1368            .expect("tree should write");
1369        let identity = b"Test User <test@example.invalid> 1 +0000".to_vec();
1370        db.write_object(EncodedObject::new(
1371            ObjectType::Commit,
1372            Commit {
1373                tree,
1374                parents,
1375                author: identity.clone(),
1376                committer: identity,
1377                encoding: None,
1378                message: format!("{message}\n").into_bytes(),
1379            }
1380            .write(),
1381        ))
1382        .expect("commit should write")
1383    }
1384
1385    fn set_ref(git_dir: &Path, name: &str, target: RefTarget) {
1386        let store = FileRefStore::new(git_dir, ObjectFormat::Sha1);
1387        let mut tx = store.transaction();
1388        tx.update(RefUpdate {
1389            name: name.to_string(),
1390            expected: None,
1391            new: target,
1392            reflog: None,
1393        });
1394        tx.commit().expect("ref should update");
1395    }
1396
1397    fn default_options() -> PushOptions {
1398        PushOptions {
1399            quiet: true,
1400            force: false,
1401        }
1402    }
1403
1404    #[test]
1405    fn push_action_plan_infers_pack_roots_from_non_delete_commands() {
1406        let repo = temp_repo("action-plan-infer-roots");
1407        let first = write_commit(&repo, Vec::new(), "first");
1408        let second = write_commit(&repo, vec![first], "second");
1409
1410        let plan = PushActionPlan::from_commands_and_infer_pack_roots(
1411            vec![
1412                PushCommand {
1413                    src: Some(first),
1414                    dst: "refs/heads/main".into(),
1415                    expected_old: None,
1416                    force: false,
1417                },
1418                PushCommand {
1419                    src: Some(second),
1420                    dst: "refs/heads/topic".into(),
1421                    expected_old: Some(first),
1422                    force: true,
1423                },
1424            ],
1425            default_options(),
1426        );
1427
1428        assert_eq!(plan.pack_objects, vec![first, second]);
1429        assert!(!plan.commands[0].force);
1430        assert!(plan.commands[1].force);
1431    }
1432
1433    #[test]
1434    fn push_action_plan_inferred_pack_roots_exclude_deletes() {
1435        let repo = temp_repo("action-plan-delete-roots");
1436        let old = write_commit(&repo, Vec::new(), "old");
1437        let new = write_commit(&repo, vec![old], "new");
1438
1439        let plan = PushActionPlan::from_commands_and_infer_pack_roots(
1440            vec![
1441                PushCommand {
1442                    src: None,
1443                    dst: "refs/heads/remove".into(),
1444                    expected_old: Some(old),
1445                    force: false,
1446                },
1447                PushCommand {
1448                    src: Some(new),
1449                    dst: "refs/heads/keep".into(),
1450                    expected_old: Some(old),
1451                    force: false,
1452                },
1453            ],
1454            default_options(),
1455        );
1456
1457        assert_eq!(plan.pack_objects, vec![new]);
1458    }
1459
1460    #[test]
1461    fn push_action_plan_inferred_pack_roots_dedupe_first_seen_order() {
1462        let repo = temp_repo("action-plan-dedupe-roots");
1463        let first = write_commit(&repo, Vec::new(), "first");
1464        let second = write_commit(&repo, Vec::new(), "second");
1465
1466        let plan = PushActionPlan::from_commands_and_infer_pack_roots(
1467            vec![
1468                PushCommand {
1469                    src: Some(second),
1470                    dst: "refs/heads/second".into(),
1471                    expected_old: None,
1472                    force: false,
1473                },
1474                PushCommand {
1475                    src: Some(first),
1476                    dst: "refs/heads/first".into(),
1477                    expected_old: None,
1478                    force: false,
1479                },
1480                PushCommand {
1481                    src: Some(second),
1482                    dst: "refs/tags/second".into(),
1483                    expected_old: None,
1484                    force: false,
1485                },
1486                PushCommand {
1487                    src: Some(first),
1488                    dst: "refs/tags/first".into(),
1489                    expected_old: None,
1490                    force: false,
1491                },
1492            ],
1493            default_options(),
1494        );
1495
1496        assert_eq!(plan.pack_objects, vec![second, first]);
1497    }
1498
1499    fn push_local_actions(
1500        local: &Path,
1501        remote: &Path,
1502        plan: &PushActionPlan,
1503    ) -> Result<PushOutcome> {
1504        let destination = PushDestination::Local {
1505            git_dir: remote.to_path_buf(),
1506            common_git_dir: remote.to_path_buf(),
1507        };
1508        let config = GitConfig::default();
1509        let mut credentials = NoCredentials;
1510        let mut progress = SilentProgress;
1511        push_actions(
1512            PushActionRequest {
1513                git_dir: local,
1514                common_git_dir: local,
1515                format: ObjectFormat::Sha1,
1516                config: &config,
1517                remote: "origin",
1518                destination: &destination,
1519                plan,
1520            },
1521            PushServices {
1522                credentials: &mut credentials,
1523                progress: &mut progress,
1524            },
1525        )
1526    }
1527
1528    #[test]
1529    fn local_push_returns_success_report_status_and_updates_ref() {
1530        let local = temp_repo("local-success");
1531        let remote = temp_repo("remote-success");
1532        let base = write_commit(&local, Vec::new(), "base");
1533        let tip = write_commit(&local, vec![base], "tip");
1534        set_ref(&local, "refs/heads/main", RefTarget::Direct(tip));
1535        set_ref(
1536            &local,
1537            "HEAD",
1538            RefTarget::Symbolic("refs/heads/main".into()),
1539        );
1540        let destination = PushDestination::Local {
1541            git_dir: remote.clone(),
1542            common_git_dir: remote.clone(),
1543        };
1544        let refspecs = vec!["refs/heads/main:refs/heads/main".to_string()];
1545        let options = default_options();
1546        let request = PushRequest {
1547            git_dir: &local,
1548            common_git_dir: &local,
1549            format: ObjectFormat::Sha1,
1550            config: &GitConfig::default(),
1551            remote: "origin",
1552            destination: &destination,
1553            refspecs: &refspecs,
1554            options: &options,
1555        };
1556        let mut credentials = NoCredentials;
1557        let mut progress = SilentProgress;
1558
1559        let outcome = push(
1560            request,
1561            PushServices {
1562                credentials: &mut credentials,
1563                progress: &mut progress,
1564            },
1565        )
1566        .expect("push should succeed");
1567
1568        assert_eq!(outcome.commands.len(), 1);
1569        let report = outcome.report.expect("local receive-pack reports status");
1570        assert!(matches!(report.unpack, ReceivePackUnpackStatus::Ok));
1571        assert!(matches!(
1572            report.commands.as_slice(),
1573            [ReceivePackCommandStatus::Ok { name }] if name == "refs/heads/main"
1574        ));
1575        let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
1576        assert_eq!(
1577            remote_refs
1578                .read_ref("refs/heads/main")
1579                .expect("remote ref should read"),
1580            Some(RefTarget::Direct(tip))
1581        );
1582    }
1583
1584    #[test]
1585    fn local_push_actions_preserves_exact_old_new_update() {
1586        let local = temp_repo("actions-update-local");
1587        let remote = temp_repo("actions-update-remote");
1588        let base = write_commit(&local, Vec::new(), "base");
1589        let remote_base = write_commit(&remote, Vec::new(), "base");
1590        assert_eq!(remote_base, base);
1591        let tip = write_commit(&local, vec![base], "tip");
1592        set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
1593        let plan = PushActionPlan::from_actions(
1594            vec![PushAction::Update {
1595                dst: "refs/heads/main".into(),
1596                old: base,
1597                new: tip,
1598            }],
1599            default_options(),
1600        );
1601
1602        let outcome = push_local_actions(&local, &remote, &plan).expect("push actions");
1603
1604        assert_eq!(outcome.commands.len(), 1);
1605        assert_eq!(outcome.commands[0].old_id, base);
1606        assert_eq!(outcome.commands[0].new_id, tip);
1607        let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
1608        assert_eq!(
1609            remote_refs
1610                .read_ref("refs/heads/main")
1611                .expect("remote ref should read"),
1612            Some(RefTarget::Direct(tip))
1613        );
1614    }
1615
1616    #[test]
1617    fn local_push_actions_honors_per_command_force() {
1618        let local = temp_repo("actions-command-force-local");
1619        let remote = temp_repo("actions-command-force-remote");
1620        let base = write_commit(&local, Vec::new(), "base");
1621        let remote_base = write_commit(&remote, Vec::new(), "base");
1622        assert_eq!(remote_base, base);
1623        let unrelated = write_commit(&local, Vec::new(), "unrelated");
1624        set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
1625
1626        let unforced = PushActionPlan::from_commands(
1627            vec![PushCommand {
1628                src: Some(unrelated),
1629                dst: "refs/heads/main".into(),
1630                expected_old: Some(base),
1631                force: false,
1632            }],
1633            default_options(),
1634        );
1635        let err = push_local_actions(&local, &remote, &unforced)
1636            .expect_err("non-fast-forward should reject without command force");
1637        assert!(err.to_string().contains("non-fast-forward"));
1638
1639        let forced = PushActionPlan::from_commands(
1640            vec![PushCommand {
1641                src: Some(unrelated),
1642                dst: "refs/heads/main".into(),
1643                expected_old: Some(base),
1644                force: true,
1645            }],
1646            default_options(),
1647        );
1648        let outcome = push_local_actions(&local, &remote, &forced).expect("command force pushes");
1649
1650        assert_eq!(outcome.commands.len(), 1);
1651        let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
1652        assert_eq!(
1653            remote_refs
1654                .read_ref("refs/heads/main")
1655                .expect("remote ref should read"),
1656            Some(RefTarget::Direct(unrelated))
1657        );
1658    }
1659
1660    #[test]
1661    fn local_push_actions_command_force_is_precise_for_non_ff_validation() {
1662        let local = temp_repo("actions-command-force-precise-local");
1663        let remote = temp_repo("actions-command-force-precise-remote");
1664        let base = write_commit(&local, Vec::new(), "base");
1665        let remote_base = write_commit(&remote, Vec::new(), "base");
1666        assert_eq!(remote_base, base);
1667        let forced_unrelated = write_commit(&local, Vec::new(), "forced unrelated");
1668        let unforced_unrelated = write_commit(&local, Vec::new(), "unforced unrelated");
1669        set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
1670        set_ref(&remote, "refs/heads/topic", RefTarget::Direct(base));
1671        let plan = PushActionPlan::from_commands_and_infer_pack_roots(
1672            vec![
1673                PushCommand {
1674                    src: Some(forced_unrelated),
1675                    dst: "refs/heads/main".into(),
1676                    expected_old: Some(base),
1677                    force: true,
1678                },
1679                PushCommand {
1680                    src: Some(unforced_unrelated),
1681                    dst: "refs/heads/topic".into(),
1682                    expected_old: Some(base),
1683                    force: false,
1684                },
1685            ],
1686            default_options(),
1687        );
1688
1689        let err = push_local_actions(&local, &remote, &plan)
1690            .expect_err("only the forced command should bypass non-fast-forward validation");
1691
1692        assert!(err.to_string().contains("non-fast-forward update to topic"));
1693        let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
1694        assert_eq!(
1695            remote_refs
1696                .read_ref("refs/heads/main")
1697                .expect("remote ref should read"),
1698            Some(RefTarget::Direct(base))
1699        );
1700        assert_eq!(
1701            remote_refs
1702                .read_ref("refs/heads/topic")
1703                .expect("remote ref should read"),
1704            Some(RefTarget::Direct(base))
1705        );
1706    }
1707
1708    #[test]
1709    fn local_push_actions_stale_update_old_rejects_without_mutating() {
1710        let local = temp_repo("actions-stale-local");
1711        let remote = temp_repo("actions-stale-remote");
1712        let base = write_commit(&local, Vec::new(), "base");
1713        let remote_base = write_commit(&remote, Vec::new(), "base");
1714        assert_eq!(remote_base, base);
1715        let tip = write_commit(&local, vec![base], "tip");
1716        let concurrent = write_commit(&remote, vec![base], "concurrent");
1717        set_ref(&remote, "refs/heads/main", RefTarget::Direct(concurrent));
1718        let plan = PushActionPlan::from_actions(
1719            vec![PushAction::Update {
1720                dst: "refs/heads/main".into(),
1721                old: base,
1722                new: tip,
1723            }],
1724            default_options(),
1725        );
1726
1727        let err = push_local_actions(&local, &remote, &plan).expect_err("stale old rejects");
1728
1729        assert!(err.to_string().contains("expected ref refs/heads/main"));
1730        let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
1731        assert_eq!(
1732            remote_refs
1733                .read_ref("refs/heads/main")
1734                .expect("remote ref should read"),
1735            Some(RefTarget::Direct(concurrent))
1736        );
1737    }
1738
1739    #[test]
1740    fn local_push_actions_stale_delete_old_rejects_without_mutating() {
1741        let local = temp_repo("actions-delete-local");
1742        let remote = temp_repo("actions-delete-remote");
1743        let base = write_commit(&local, Vec::new(), "base");
1744        let remote_base = write_commit(&remote, Vec::new(), "base");
1745        assert_eq!(remote_base, base);
1746        let concurrent = write_commit(&remote, vec![base], "concurrent");
1747        set_ref(&remote, "refs/heads/main", RefTarget::Direct(concurrent));
1748        let plan = PushActionPlan::from_actions(
1749            vec![PushAction::Delete {
1750                dst: "refs/heads/main".into(),
1751                old: Some(base),
1752            }],
1753            default_options(),
1754        );
1755
1756        let err = push_local_actions(&local, &remote, &plan).expect_err("stale delete rejects");
1757
1758        assert!(err.to_string().contains("expected ref refs/heads/main"));
1759        let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
1760        assert_eq!(
1761            remote_refs
1762                .read_ref("refs/heads/main")
1763                .expect("remote ref should read"),
1764            Some(RefTarget::Direct(concurrent))
1765        );
1766    }
1767
1768    #[test]
1769    fn local_push_actions_create_rejects_existing_ref() {
1770        let local = temp_repo("actions-create-local");
1771        let remote = temp_repo("actions-create-remote");
1772        let base = write_commit(&local, Vec::new(), "base");
1773        let remote_base = write_commit(&remote, Vec::new(), "base");
1774        assert_eq!(remote_base, base);
1775        let tip = write_commit(&local, vec![base], "tip");
1776        set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
1777        let plan = PushActionPlan::from_actions(
1778            vec![PushAction::Create {
1779                dst: "refs/heads/main".into(),
1780                new: tip,
1781            }],
1782            default_options(),
1783        );
1784
1785        let err = push_local_actions(&local, &remote, &plan).expect_err("create must be absent");
1786
1787        assert!(
1788            err.to_string()
1789                .contains("expected ref refs/heads/main to not already exist")
1790        );
1791        let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
1792        assert_eq!(
1793            remote_refs
1794                .read_ref("refs/heads/main")
1795                .expect("remote ref should read"),
1796            Some(RefTarget::Direct(base))
1797        );
1798    }
1799
1800    #[test]
1801    fn report_status_rejection_is_an_error() {
1802        let report = ReceivePackReportStatus {
1803            unpack: ReceivePackUnpackStatus::Ok,
1804            commands: vec![ReceivePackCommandStatus::Ng {
1805                name: "refs/heads/main".into(),
1806                message: "hook declined".into(),
1807            }],
1808        };
1809
1810        let err = validate_receive_pack_report(&report).expect_err("ng report should fail");
1811
1812        assert!(err.to_string().contains("hook declined"));
1813    }
1814
1815    #[test]
1816    fn failed_local_push_does_not_partially_mutate_remote_ref() {
1817        let local = temp_repo("local-rejected");
1818        let remote = temp_repo("remote-rejected");
1819        let base = write_commit(&local, Vec::new(), "base");
1820        let planned = write_commit(&local, vec![base], "planned");
1821        let concurrent = write_commit(&local, vec![base], "concurrent");
1822        set_ref(&local, "refs/heads/main", RefTarget::Direct(planned));
1823        set_ref(
1824            &local,
1825            "HEAD",
1826            RefTarget::Symbolic("refs/heads/main".into()),
1827        );
1828        set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
1829        let destination = PushDestination::Local {
1830            git_dir: remote.clone(),
1831            common_git_dir: remote.clone(),
1832        };
1833        let refspecs = vec!["refs/heads/main:refs/heads/main".to_string()];
1834        let options = default_options();
1835        let request = PushRequest {
1836            git_dir: &local,
1837            common_git_dir: &local,
1838            format: ObjectFormat::Sha1,
1839            config: &GitConfig::default(),
1840            remote: "origin",
1841            destination: &destination,
1842            refspecs: &refspecs,
1843            options: &options,
1844        };
1845        let mut credentials = NoCredentials;
1846        let mut progress = SilentProgress;
1847        let mut services = PushServices {
1848            credentials: &mut credentials,
1849            progress: &mut progress,
1850        };
1851        let plan = plan_push(request, &mut services).expect("push should plan");
1852
1853        set_ref(&remote, "refs/heads/main", RefTarget::Direct(concurrent));
1854        let _err = execute_push_plan(request, &mut services, plan)
1855            .expect_err("stale old id should reject the ref update");
1856
1857        let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
1858        assert_eq!(
1859            remote_refs
1860                .read_ref("refs/heads/main")
1861                .expect("remote ref should read"),
1862            Some(RefTarget::Direct(concurrent))
1863        );
1864    }
1865}