Skip to main content

grit_lib/remote/
push_plan.rs

1//! Resolve remotes and refspecs for [`Remote::push`](crate::remote::Remote::push).
2//!
3//! [`resolve_push`] turns a [`PushRequest`] (CLI flags and positional refspecs)
4//! into a [`ResolvedPush`] of concrete [`PushRefSpec`] entries, matching Git's
5//! push refspec and remote-selection rules.
6//!
7//! A typical push classifies its positionals with [`resolve_push_invocation`], loads the remote's
8//! advertisement with [`load_push_plan_context`], plans with [`resolve_push`], and sends the
9//! result through the [`Remote`] returned by [`open_push_destination`].
10
11use std::collections::{HashMap, HashSet};
12use std::path::Path;
13
14use thiserror::Error;
15
16use crate::branch_tracking::{apply_push_set_upstream, remote_tracking_ref_for_remote_ref};
17use crate::config::gitmodules::gitdir::is_git_directory;
18use crate::config::{parse_git_config_maybe_bool, ConfigSet};
19use crate::merge_base::is_ancestor;
20use crate::objects::{ObjectId, ObjectKind};
21use crate::push_report::PushRefResult;
22use crate::refs;
23use crate::refspec::{parse_push_refspec, RefspecItem};
24use crate::remote::{HttpClientFactory, ProbeHttpClientFactory, Remote, RemoteError, RemoteRef};
25use crate::repo::Repository;
26use crate::repo_handles::has_remote_section;
27use crate::rev_parse::{resolve_push_full_ref_for_branch, resolve_revision_for_verify};
28use crate::state::{resolve_head, HeadState};
29use crate::transfer::{resolve_push_delete_ref, PushRefSpec};
30
31pub use crate::remote::DEFAULT_REMOTE;
32
33/// How non-fast-forward updates are allowed during a push.
34#[derive(Debug, Clone, Default, PartialEq, Eq)]
35pub enum ForceMode {
36    /// Reject non-fast-forward updates (default).
37    #[default]
38    None,
39    /// Unconditional force (`--force`).
40    Force,
41    /// Guarded force (`--force-with-lease`).
42    Lease(LeaseExpectation),
43}
44
45/// What a force-with-lease push compares the remote ref against.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum LeaseExpectation {
48    /// Use the local remote-tracking ref mapped from the push destination.
49    Tracking,
50    /// Use an explicit object id (`--force-with-lease=<rev>`); only valid for a single ref.
51    Value(ObjectId),
52}
53
54/// What the caller wants to push (remote override, refspecs, tags, deletions).
55#[derive(Debug, Clone, Default, PartialEq, Eq)]
56pub struct PushRequest {
57    /// Explicit remote name from `--remote` or the first positional argument.
58    pub remote: Option<String>,
59    /// Positional refspec strings (`src`, `src:dst`, `:dst`, `+src:dst`, …).
60    pub refspecs: Vec<String>,
61    /// Push all local tags (`--tags`).
62    pub tags: bool,
63    /// Delete the remote refs named in [`Self::refspecs`] instead of pushing them (`--delete`).
64    ///
65    /// Every refspec must then be a plain ref name (no `:`); see
66    /// [`PushPlanError::DeleteNotPlainName`].
67    pub delete_refs: bool,
68    /// Additional remote refs to delete, planned together with [`Self::refspecs`] as when
69    /// [`Self::delete_refs`] is set.
70    pub delete: Vec<String>,
71    /// Force or force-with-lease mode from CLI flags.
72    pub force: ForceMode,
73    /// Push annotated tags reachable from the commits being pushed (`--follow-tags`).
74    pub follow_tags: bool,
75    /// After a successful branch push, write `branch.<name>.remote` / `.merge` (`-u`).
76    pub set_upstream: bool,
77}
78
79/// A fully resolved push target.
80#[derive(Debug, Clone)]
81pub struct ResolvedPush {
82    /// Remote name to push to.
83    pub remote: String,
84    /// Concrete ref updates to send.
85    pub specs: Vec<PushRefSpec>,
86    /// Short branch label when the push maps to one local branch (for display).
87    pub branch_label: Option<String>,
88    /// High-level kind for JSON (`branch`, `tags`, `delete`, `refspecs`).
89    pub kind: PushPlanKind,
90}
91
92/// JSON / CLI classification of what was pushed.
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum PushPlanKind {
95    /// Default push of the current branch (or a single mapped branch).
96    Branch,
97    /// Push all local tags (`--tags`).
98    Tags,
99    /// Delete one or more remote refs (`--delete`).
100    Delete,
101    /// Explicit refspecs from the command line or remote `push` config.
102    Refspecs,
103}
104
105/// Extra inputs needed to DWIM delete targets and short push destinations.
106#[derive(Debug, Clone, Default)]
107pub struct PushPlanContext {
108    /// Advertised ref names on the remote (for delete mapping and dst guessing).
109    pub remote_ref_names: HashSet<String>,
110    /// Tips of `refs/heads/*` on the remote (reserved for push planning; follow-tags
111    /// uses commit-graph ancestor queries instead of materializing reachable sets).
112    pub remote_branch_tips: HashSet<ObjectId>,
113    /// Tip of every advertised ref, or `None` when the advertisement was not read.
114    ///
115    /// Pass it on as [`PushOptions::remote_ref_tips`](crate::transfer::PushOptions) so a local
116    /// push does not scan the remote's refs a second time. Only pass it to a push to the same
117    /// remote: [`load_push_plan_context`] and [`resolve_push`] select the same remote when given
118    /// the same override.
119    pub remote_tips: Option<Vec<ObjectId>>,
120}
121
122/// Errors while planning a push (typed; no stringly matching in callers).
123#[derive(Debug, Error, PartialEq, Eq)]
124pub enum PushPlanError {
125    /// Detached `HEAD` with no refspec on the command line.
126    #[error("HEAD is detached; specify a refspec (for example HEAD:refs/heads/<name>)")]
127    DetachedHeadNoRefspec,
128    /// Current branch exists but has no commits.
129    #[error("no commits yet to push")]
130    NoCommits,
131    /// `--tags` was given but there are no local tags.
132    #[error("no tags to push")]
133    NoTags,
134    /// `push.default = nothing` blocks an empty refspec push.
135    #[error("push.default is nothing; no push destination")]
136    PushDefaultNothing,
137    /// No remote was named and none is configured: the branch has no push remote or remote,
138    /// `remote.pushDefault` is unset and there is no `origin` with a URL.
139    #[error("no remote to push to")]
140    NoRemote,
141    /// Named remote is missing from config.
142    #[error("unknown remote '{0}'")]
143    UnknownRemote(String),
144    /// Malformed refspec string.
145    #[error("invalid refspec '{spec}': {reason}")]
146    InvalidRefspec {
147        /// Original refspec text.
148        spec: String,
149        /// Parse or validation detail.
150        reason: String,
151    },
152    /// Source side did not match any local ref or object.
153    #[error("src refspec {spec} does not match any ref")]
154    SrcRefspecNoMatch {
155        /// Source pattern or name.
156        spec: String,
157    },
158    /// Source side matched more than one local ref.
159    #[error("src refspec {spec} matches more than one ref")]
160    SrcRefspecAmbiguous {
161        /// Source pattern or name.
162        spec: String,
163    },
164    /// Short destination matched multiple remote refs.
165    #[error("dst refspec {spec} matches more than one remote ref")]
166    DstRefspecAmbiguous {
167        /// Destination pattern or name.
168        spec: String,
169    },
170    /// Revision text could not be resolved to an object.
171    #[error("could not resolve revision '{spec}'")]
172    UnknownRevision {
173        /// Revision expression.
174        spec: String,
175    },
176    /// `--force-with-lease=<rev>` with more than one ref update.
177    #[error("--force-with-lease=<rev> requires pushing exactly one ref")]
178    LeaseValueRequiresSingleRef,
179    /// A delete push was given a refspec (`src:dst`) instead of a plain remote ref name.
180    #[error("cannot delete '{spec}': --delete takes ref names, not src:dst refspecs")]
181    DeleteNotPlainName {
182        /// The offending refspec.
183        spec: String,
184    },
185    /// A delete push named no ref to delete.
186    #[error("--delete needs at least one ref to delete")]
187    DeleteWithoutRefs,
188    /// Other planning failure.
189    #[error("{0}")]
190    Message(String),
191}
192
193type PlanResult<T> = std::result::Result<T, PushPlanError>;
194
195/// Parse `--force` / `--force-with-lease[=<rev>]` into [`ForceMode`].
196pub fn parse_force_mode(
197    repo: &Repository,
198    force: bool,
199    force_with_lease: Option<Option<String>>,
200) -> PlanResult<ForceMode> {
201    if force {
202        return Ok(ForceMode::Force);
203    }
204    let Some(flag) = force_with_lease else {
205        return Ok(ForceMode::None);
206    };
207    match flag {
208        None => Ok(ForceMode::Lease(LeaseExpectation::Tracking)),
209        Some(rev) => {
210            let oid = crate::rev_parse::resolve_revision_for_verify(repo, &rev)
211                .map_err(|_| PushPlanError::UnknownRevision { spec: rev.clone() })?;
212            Ok(ForceMode::Lease(LeaseExpectation::Value(oid)))
213        }
214    }
215}
216
217/// Resolve `request` into a remote name and [`PushRefSpec`] list.
218///
219/// When `ctx.remote_ref_names` is empty, delete/ref DWIM still uses local refs
220/// and falls back to Git's default `refs/heads/<name>` for deletes.
221///
222/// A delete push ([`PushRequest::delete_refs`] or a non-empty [`PushRequest::delete`]) plans one
223/// deletion per name in `delete` and `refspecs`, and nothing else.
224///
225/// # Errors
226///
227/// Besides remote and refspec resolution failures: [`PushPlanError::DeleteNotPlainName`] when a
228/// delete push is given a `src:dst` refspec, and [`PushPlanError::DeleteWithoutRefs`] when it
229/// names no ref. Both are reported before the remote is contacted.
230pub fn resolve_push(
231    repo: &Repository,
232    config: &ConfigSet,
233    request: &PushRequest,
234    ctx: &PushPlanContext,
235) -> PlanResult<ResolvedPush> {
236    if request.delete_refs || !request.delete.is_empty() {
237        return resolve_delete_push(repo, config, request, ctx);
238    }
239    if request.tags {
240        return resolve_tags_push(repo, config, request);
241    }
242
243    let remote = configured_remote_for_push(repo, config, request.remote.as_deref())?;
244    validate_remote(config, &remote)?;
245
246    let follow_tags = request.follow_tags || push_follow_tags_config(config);
247
248    let mut resolved = if request.refspecs.is_empty() {
249        resolve_default_push(repo, config, &remote, ctx)?
250    } else {
251        let local = LocalPushRefs::load(repo)?;
252        let mut specs = Vec::new();
253        for spec in &request.refspecs {
254            specs.extend(resolve_cli_refspec(
255                repo, config, &remote, spec, ctx, &local,
256            )?);
257        }
258        let branch_label = branch_label_from_specs(repo, &specs);
259        ResolvedPush {
260            remote,
261            specs,
262            branch_label,
263            kind: PushPlanKind::Refspecs,
264        }
265    };
266
267    if follow_tags {
268        let extra = follow_tag_push_specs(repo, &resolved.specs, ctx)?;
269        if !extra.is_empty() {
270            resolved.specs.extend(extra);
271            resolved.kind = PushPlanKind::Refspecs;
272            resolved.branch_label = None;
273        }
274    }
275
276    apply_force_mode(
277        repo,
278        config,
279        &resolved.remote,
280        &mut resolved.specs,
281        &request.force,
282    )?;
283    Ok(resolved)
284}
285
286/// Whether `push.followTags` is enabled in config (default false).
287#[must_use]
288pub fn push_follow_tags_config(config: &ConfigSet) -> bool {
289    config
290        .get("push.followTags")
291        .and_then(|v| parse_git_config_maybe_bool(&v))
292        .unwrap_or(false)
293}
294
295/// Append annotated tag refspecs reachable from the updates in `specs`.
296///
297/// Matches `git push --follow-tags`: lightweight tags are skipped, tags already on the remote
298/// are skipped, and reachability uses commit-graph ancestor checks from pushed tips (any ref
299/// namespace). Tag refspecs and specs whose source is a tag object do not seed the walk.
300pub fn follow_tag_push_specs(
301    repo: &Repository,
302    specs: &[PushRefSpec],
303    ctx: &PushPlanContext,
304) -> PlanResult<Vec<PushRefSpec>> {
305    let tips = push_follow_tip_commits(repo, specs)?;
306    if tips.is_empty() {
307        return Ok(Vec::new());
308    }
309
310    let existing: HashSet<&str> = specs.iter().map(|s| s.dst.as_str()).collect();
311    let odb = repo.odb();
312    let mut out = Vec::new();
313    for (tag_name, tag_oid) in list_local_refs(repo.git_dir(), "refs/tags/")? {
314        if ctx.remote_ref_names.contains(&tag_name) || existing.contains(tag_name.as_str()) {
315            continue;
316        }
317        let Ok(obj) = odb.read(&tag_oid) else {
318            continue;
319        };
320        if obj.kind != ObjectKind::Tag {
321            continue;
322        }
323        let Ok((peeled, _)) = odb.peel_tags(tag_oid) else {
324            continue;
325        };
326        if !tag_reachable_from_push_tips(repo, peeled, &tips)? {
327            continue;
328        }
329        out.push(push_spec(
330            Some(tag_oid),
331            Some(tag_name.clone()),
332            tag_name,
333            false,
334            false,
335        ));
336    }
337    Ok(out)
338}
339
340/// After a successful push, apply `-u` / `--set-upstream` when [`PushRequest::set_upstream`] was set.
341///
342/// Returns the short names of branches whose upstream config was written. No-op when `set_upstream`
343/// is false or `dry_run` is true.
344pub fn apply_set_upstream_after_push(
345    repo: &Repository,
346    remote: &str,
347    set_upstream: bool,
348    dry_run: bool,
349    specs: &[PushRefSpec],
350    results: &[PushRefResult],
351) -> Result<Vec<String>, crate::error::Error> {
352    if !set_upstream || dry_run {
353        return Ok(Vec::new());
354    }
355    apply_push_set_upstream(repo, remote, specs, results)
356}
357
358fn push_follow_tip_commits(repo: &Repository, specs: &[PushRefSpec]) -> PlanResult<Vec<ObjectId>> {
359    let mut tips = Vec::new();
360    for spec in specs {
361        if spec.delete || is_tag_push_spec(repo, spec) {
362            continue;
363        }
364        if let Some(commit) = peel_push_src_to_commit(repo, spec) {
365            tips.push(commit);
366        }
367    }
368    Ok(tips)
369}
370
371fn is_tag_push_spec(repo: &Repository, spec: &PushRefSpec) -> bool {
372    if spec.dst.starts_with("refs/tags/") {
373        return true;
374    }
375    let Some(src) = spec.src else {
376        return false;
377    };
378    let Ok(obj) = repo.odb().read(&src) else {
379        return false;
380    };
381    obj.kind == ObjectKind::Tag
382}
383
384fn peel_push_src_to_commit(repo: &Repository, spec: &PushRefSpec) -> Option<ObjectId> {
385    let src = spec.src?;
386    let odb = repo.odb();
387    let Ok(obj) = odb.read(&src) else {
388        return None;
389    };
390    match obj.kind {
391        ObjectKind::Commit => Some(src),
392        ObjectKind::Tag => odb.peel_tags(src).ok().map(|(commit, _)| commit),
393        _ => None,
394    }
395}
396
397fn tag_reachable_from_push_tips(
398    repo: &Repository,
399    peeled: ObjectId,
400    tips: &[ObjectId],
401) -> PlanResult<bool> {
402    for &tip in tips {
403        if peeled == tip {
404            return Ok(true);
405        }
406        if is_ancestor(repo, peeled, tip).map_err(|e| PushPlanError::Message(e.to_string()))? {
407            return Ok(true);
408        }
409    }
410    Ok(false)
411}
412
413fn resolve_delete_push(
414    repo: &Repository,
415    config: &ConfigSet,
416    request: &PushRequest,
417    ctx: &PushPlanContext,
418) -> PlanResult<ResolvedPush> {
419    let names: Vec<&String> = request.delete.iter().chain(&request.refspecs).collect();
420    if let Some(spec) = names.iter().find(|name| name.contains(':')) {
421        return Err(PushPlanError::DeleteNotPlainName {
422            spec: (*spec).clone(),
423        });
424    }
425    if names.is_empty() {
426        return Err(PushPlanError::DeleteWithoutRefs);
427    }
428    let remote = configured_remote_for_push(repo, config, request.remote.as_deref())?;
429    validate_remote(config, &remote)?;
430    let specs = names
431        .into_iter()
432        .map(|name| {
433            let dst = resolve_push_delete_ref(repo.git_dir(), &ctx.remote_ref_names, name);
434            push_spec(None, None, dst, false, true)
435        })
436        .collect::<Vec<_>>();
437    let branch_label = delete_branch_label(&specs);
438    let mut resolved = ResolvedPush {
439        remote,
440        specs,
441        branch_label,
442        kind: PushPlanKind::Delete,
443    };
444    apply_force_mode(
445        repo,
446        config,
447        &resolved.remote,
448        &mut resolved.specs,
449        &request.force,
450    )?;
451    Ok(resolved)
452}
453
454fn resolve_tags_push(
455    repo: &Repository,
456    config: &ConfigSet,
457    request: &PushRequest,
458) -> PlanResult<ResolvedPush> {
459    let remote = configured_remote_for_push(repo, config, request.remote.as_deref())?;
460    validate_remote(config, &remote)?;
461    let entries = refs::list_refs(repo.git_dir(), "refs/tags/")
462        .map_err(|e| PushPlanError::Message(e.to_string()))?;
463    if entries.is_empty() {
464        return Err(PushPlanError::NoTags);
465    }
466    let specs = entries
467        .into_iter()
468        .map(|(refname, oid)| push_spec(Some(oid), Some(refname.clone()), refname, false, false))
469        .collect();
470    let mut resolved = ResolvedPush {
471        remote,
472        specs,
473        branch_label: None,
474        kind: PushPlanKind::Tags,
475    };
476    apply_force_mode(
477        repo,
478        config,
479        &resolved.remote,
480        &mut resolved.specs,
481        &request.force,
482    )?;
483    Ok(resolved)
484}
485
486fn resolve_default_push(
487    repo: &Repository,
488    config: &ConfigSet,
489    remote: &str,
490    ctx: &PushPlanContext,
491) -> PlanResult<ResolvedPush> {
492    let push_specs = remote_push_refspec_strings(config, remote);
493    if !push_specs.is_empty() {
494        let mut positive_specs = Vec::new();
495        let mut negative_items = Vec::new();
496        for spec in push_specs {
497            let item = parse_push_refspec(&spec).map_err(|e| PushPlanError::InvalidRefspec {
498                spec: spec.clone(),
499                reason: e.to_string(),
500            })?;
501            if item.negative {
502                negative_items.push(item);
503            } else {
504                positive_specs.push(spec);
505            }
506        }
507        let local = LocalPushRefs::load(repo)?;
508        let mut specs = Vec::new();
509        for spec in positive_specs {
510            specs.extend(expand_configured_push_refspec(repo, spec, ctx, &local)?);
511        }
512        for item in negative_items {
513            specs.retain(|s| !push_spec_excluded_by_negative(&item, s));
514        }
515        let branch_label = branch_label_from_specs(repo, &specs);
516        let kind = if specs.len() == 1 {
517            PushPlanKind::Branch
518        } else {
519            PushPlanKind::Refspecs
520        };
521        return Ok(ResolvedPush {
522            remote: remote.to_owned(),
523            specs,
524            branch_label,
525            kind,
526        });
527    }
528
529    let push_default = config
530        .get("push.default")
531        .unwrap_or_else(|| "simple".to_owned());
532    if push_default == "matching" {
533        return resolve_matching_default_push(repo, remote, ctx);
534    }
535
536    let (short_name, oid) = current_branch_oid(repo)?;
537    let src_ref = format!("refs/heads/{short_name}");
538    let dst = default_push_destination(repo, config, remote, &short_name, &src_ref)?;
539    let spec = push_spec(Some(oid), Some(src_ref.clone()), dst, false, false);
540    Ok(ResolvedPush {
541        remote: remote.to_owned(),
542        specs: vec![spec],
543        branch_label: Some(short_name),
544        kind: PushPlanKind::Branch,
545    })
546}
547
548/// `push.default = matching`: push every local branch whose name exists on the remote.
549fn resolve_matching_default_push(
550    repo: &Repository,
551    remote: &str,
552    ctx: &PushPlanContext,
553) -> PlanResult<ResolvedPush> {
554    let heads = list_local_refs(repo.git_dir(), "refs/heads/")?;
555    let mut specs = Vec::new();
556    for (name, oid) in heads {
557        if ctx.remote_ref_names.contains(&name) {
558            specs.push(push_spec(
559                Some(oid),
560                Some(name.clone()),
561                name.clone(),
562                false,
563                false,
564            ));
565        }
566    }
567    let branch_label = branch_label_from_specs(repo, &specs);
568    let kind = if specs.len() == 1 {
569        PushPlanKind::Branch
570    } else {
571        PushPlanKind::Refspecs
572    };
573    Ok(ResolvedPush {
574        remote: remote.to_owned(),
575        specs,
576        branch_label,
577        kind,
578    })
579}
580
581fn current_branch_oid(repo: &Repository) -> PlanResult<(String, ObjectId)> {
582    match resolve_head(repo.git_dir()).map_err(|e| PushPlanError::Message(e.to_string()))? {
583        HeadState::Branch {
584            short_name,
585            oid: Some(oid),
586            ..
587        } => Ok((short_name, oid)),
588        HeadState::Branch { .. } => Err(PushPlanError::NoCommits),
589        HeadState::Detached { .. } => Err(PushPlanError::DetachedHeadNoRefspec),
590        HeadState::Invalid => Err(PushPlanError::Message("HEAD is in an unknown state".into())),
591    }
592}
593
594/// Remote selection for push: explicit, `branch.*.pushRemote`, `remote.pushDefault`,
595/// `branch.*.remote`, then `origin`.
596///
597/// # Errors
598///
599/// [`PushPlanError::NoRemote`] when nothing selects a remote and no `remote.origin.*` section
600/// is configured.
601pub fn configured_remote_for_push(
602    repo: &Repository,
603    config: &ConfigSet,
604    remote_override: Option<&str>,
605) -> PlanResult<String> {
606    if let Some(remote) = remote_override.filter(|r| !r.trim().is_empty()) {
607        return Ok(remote.to_owned());
608    }
609    if let Ok(HeadState::Branch { short_name, .. }) = resolve_head(repo.git_dir()) {
610        if let Some(remote) = config
611            .get(&format!("branch.{short_name}.pushremote"))
612            .or_else(|| config.get(&format!("branch.{short_name}.pushRemote")))
613            .filter(|r| !r.trim().is_empty())
614        {
615            return Ok(remote);
616        }
617    }
618    if let Some(remote) = config
619        .get("remote.pushDefault")
620        .filter(|r| !r.trim().is_empty())
621    {
622        return Ok(remote);
623    }
624    if let Ok(HeadState::Branch { short_name, .. }) = resolve_head(repo.git_dir()) {
625        if let Some(remote) = config
626            .get(&format!("branch.{short_name}.remote"))
627            .filter(|r| !r.trim().is_empty() && r != ".")
628        {
629            return Ok(remote);
630        }
631    }
632    if !has_remote_section(config, DEFAULT_REMOTE) {
633        return Err(PushPlanError::NoRemote);
634    }
635    Ok(DEFAULT_REMOTE.to_owned())
636}
637
638/// Remote and refspec strings after classifying `grit push [<remote>] [<refspec>...]`.
639#[derive(Debug, Clone, Default, PartialEq, Eq)]
640pub struct PushInvocation {
641    /// Remote name, URL or path; `None` selects the remote with [`configured_remote_for_push`].
642    pub remote: Option<String>,
643    /// Refspec strings for [`PushRequest::refspecs`].
644    pub refspecs: Vec<String>,
645}
646
647/// Classify push positionals into a remote and refspecs.
648///
649/// With `remote_flag` set, every positional is a refspec. Otherwise the first positional is the
650/// remote when it has a configured `remote.<name>.url` or looks like a literal URL or path
651/// (`scheme://`, `*.git`, `/abs/path`, `.` / `..`, `./…` / `../…`, or an existing relative git
652/// directory). A single other positional is a refspec pushed to the default remote; with more
653/// positionals the first one must name a remote.
654///
655/// # Errors
656///
657/// [`PushPlanError::UnknownRemote`] when several positionals are given and the first is neither a
658/// configured remote nor a literal URL or path.
659pub fn resolve_push_invocation(
660    config: &ConfigSet,
661    remote_flag: Option<&str>,
662    positionals: &[String],
663) -> PlanResult<PushInvocation> {
664    if let Some(remote) = remote_flag {
665        return Ok(PushInvocation {
666            remote: Some(remote.to_owned()),
667            refspecs: positionals.to_vec(),
668        });
669    }
670    let Some((first, rest)) = positionals.split_first() else {
671        return Ok(PushInvocation::default());
672    };
673    if has_remote_url(config, first) || looks_like_literal_remote(first) {
674        return Ok(PushInvocation {
675            remote: Some(first.clone()),
676            refspecs: rest.to_vec(),
677        });
678    }
679    if !rest.is_empty() {
680        return Err(PushPlanError::UnknownRemote(first.clone()));
681    }
682    Ok(PushInvocation {
683        remote: None,
684        refspecs: positionals.to_vec(),
685    })
686}
687
688/// The URL `<host>/<owner>/<project>.git` when `grit push <owner>/<project> <refspec>...` names
689/// a repository on a grit host rather than a remote.
690///
691/// Only a first positional followed by refspecs is a remote, so a lone `topic/x` stays a
692/// refspec (`None`). A configured remote of the same name wins, and literal URLs and paths
693/// (`a/b/c`, `./a/b`, `x/y.git`) are left alone. Owner and project are letters, digits, `-`,
694/// `_` and `.`, not starting with `.`.
695#[must_use]
696pub fn expand_repository_shorthand(
697    config: &ConfigSet,
698    positionals: &[String],
699    host: &str,
700) -> Option<String> {
701    let [first, _, ..] = positionals else {
702        return None;
703    };
704    if has_remote_url(config, first) || looks_like_literal_remote(first) {
705        return None;
706    }
707    let is_segment = |s: &str| {
708        !s.is_empty()
709            && !s.starts_with('.')
710            && s.chars()
711                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
712    };
713    let (owner, project) = first.split_once('/')?;
714    (is_segment(owner) && is_segment(project))
715        .then(|| format!("{}/{owner}/{project}.git", host.trim_end_matches('/')))
716}
717
718/// Open the [`Remote`] a resolved push sends to ([`ResolvedPush::remote`]).
719///
720/// Literal URLs and paths (see [`resolve_push_invocation`]) become anonymous remotes; any other
721/// name is read from `remote.<name>.*` config.
722///
723/// # Errors
724///
725/// [`RemoteError::NoUrl`] when `name` has no configured URL, or a URL parse error.
726pub fn open_push_destination(config: &ConfigSet, name: &str) -> Result<Remote, RemoteError> {
727    if looks_like_literal_remote(name) {
728        Remote::from_url(name)
729    } else {
730        Remote::from_config(config, name)
731    }
732}
733
734impl PushPlanContext {
735    /// Build a context from a remote's ref advertisement (peeled `^{}` entries are skipped).
736    #[must_use]
737    pub fn from_advertised_refs(refs: impl IntoIterator<Item = RemoteRef>) -> Self {
738        let mut ctx = Self::default();
739        let mut tips = Vec::new();
740        for remote_ref in refs {
741            if remote_ref.peeled_tag().is_some() {
742                continue;
743            }
744            if remote_ref.name.starts_with("refs/heads/") {
745                ctx.remote_branch_tips.insert(remote_ref.oid);
746            }
747            tips.push(remote_ref.oid);
748            ctx.remote_ref_names.insert(remote_ref.name);
749        }
750        ctx.remote_tips = Some(tips);
751        ctx
752    }
753}
754
755/// Load the [`PushPlanContext`] for the remote a push would select.
756///
757/// The remote is chosen with [`configured_remote_for_push`], opened with
758/// [`open_push_destination`] (so literal URLs and paths are listed like configured remotes) and
759/// listed with [`Remote::list_push_refs`]. The context is best-effort: a remote without a URL, or one whose refs cannot be listed,
760/// yields an empty context, and the push itself reports any connection failure.
761///
762/// The listing uses a [probe client](HttpClientFactory::create_for_probe), so credentials the
763/// server refuses stay stored and the push reports them as rejected
764/// ([`AuthFailure::Rejected`](crate::error::AuthFailure::Rejected)).
765///
766/// # Errors
767///
768/// Only errors from [`configured_remote_for_push`].
769pub fn load_push_plan_context(
770    repo: &Repository,
771    config: &ConfigSet,
772    remote_override: Option<&str>,
773    http_factory: Option<&dyn HttpClientFactory>,
774) -> PlanResult<PushPlanContext> {
775    let remote = configured_remote_for_push(repo, config, remote_override)?;
776    let Ok(handle) = open_push_destination(config, &remote) else {
777        return Ok(PushPlanContext::default());
778    };
779    let probe = ProbeHttpClientFactory::new(http_factory, repo);
780    let Ok(advertised) = handle.list_push_refs(repo, Some(&probe)) else {
781        return Ok(PushPlanContext::default());
782    };
783    Ok(PushPlanContext::from_advertised_refs(advertised))
784}
785
786fn looks_like_literal_remote(name: &str) -> bool {
787    if name.contains("://") || name.ends_with(".git") || name.starts_with('/') {
788        return true;
789    }
790    if name == "." || name == ".." || name.starts_with("./") || name.starts_with("../") {
791        return true;
792    }
793    is_git_directory(Path::new(name))
794}
795
796fn has_remote_url(config: &ConfigSet, name: &str) -> bool {
797    config
798        .get(&format!("remote.{name}.url"))
799        .is_some_and(|u| !u.trim().is_empty())
800}
801
802fn validate_remote(config: &ConfigSet, name: &str) -> PlanResult<()> {
803    if has_remote_url(config, name) {
804        Ok(())
805    } else if name == DEFAULT_REMOTE || looks_like_literal_remote(name) {
806        // Literal URL / path remotes are resolved by the transport layer.
807        Ok(())
808    } else {
809        Err(PushPlanError::UnknownRemote(name.to_owned()))
810    }
811}
812
813fn remote_push_refspec_strings(config: &ConfigSet, remote: &str) -> Vec<String> {
814    config
815        .get_all(&format!("remote.{remote}.push"))
816        .into_iter()
817        .filter(|s| !s.trim().is_empty())
818        .collect()
819}
820
821fn default_push_destination(
822    repo: &Repository,
823    config: &ConfigSet,
824    remote: &str,
825    branch_short: &str,
826    _src_ref: &str,
827) -> PlanResult<String> {
828    let push_default = config
829        .get("push.default")
830        .unwrap_or_else(|| "simple".to_owned());
831
832    if push_default == "nothing" {
833        return Err(PushPlanError::PushDefaultNothing);
834    }
835
836    if push_default.as_str() == "upstream" {
837        let merge = config
838            .get(&format!("branch.{branch_short}.merge"))
839            .filter(|m| m.starts_with("refs/"));
840        return merge.ok_or_else(|| {
841            PushPlanError::Message(format!(
842                "branch '{branch_short}' has no upstream for push.default upstream"
843            ))
844        });
845    }
846
847    // `simple`, `current`, and unspecified: grit keeps the historical default of
848    // `branch.<name>.merge` when set, else same branch name on the remote.
849    if push_default.as_str() == "simple" {
850        let branch_remote = config.get(&format!("branch.{branch_short}.remote"));
851        if branch_remote.as_deref() == Some(remote) {
852            if let Some(merge) = config
853                .get(&format!("branch.{branch_short}.merge"))
854                .filter(|m| m.starts_with("refs/"))
855            {
856                let _merge_branch = merge.strip_prefix("refs/heads/").unwrap_or(merge.as_str());
857                let same_name_dst = format!("refs/heads/{branch_short}");
858                if merge != same_name_dst {
859                    // Git `simple` dies when upstream name differs; grit uses merge ref when same remote.
860                    return Ok(merge);
861                }
862                return Ok(same_name_dst);
863            }
864        }
865    }
866
867    let _ = resolve_push_full_ref_for_branch(repo, branch_short);
868    Ok(format!("refs/heads/{branch_short}"))
869}
870
871fn resolve_cli_refspec(
872    repo: &Repository,
873    config: &ConfigSet,
874    remote: &str,
875    spec: &str,
876    ctx: &PushPlanContext,
877    local: &LocalPushRefs,
878) -> PlanResult<Vec<PushRefSpec>> {
879    if !spec.contains(':') {
880        return resolve_shorthand_refspec(repo, config, remote, spec, ctx, local);
881    }
882    let item = parse_push_refspec(spec).map_err(|e| PushPlanError::InvalidRefspec {
883        spec: spec.to_owned(),
884        reason: e.to_string(),
885    })?;
886    resolve_parsed_refspec(repo, &item, ctx, local)
887}
888
889fn resolve_shorthand_refspec(
890    repo: &Repository,
891    config: &ConfigSet,
892    remote: &str,
893    spec: &str,
894    ctx: &PushPlanContext,
895    local: &LocalPushRefs,
896) -> PlanResult<Vec<PushRefSpec>> {
897    let count = local.count_refname_match(spec);
898    if count == 1 {
899        let Some(matched) = local.refname_match(spec) else {
900            return Err(PushPlanError::SrcRefspecNoMatch {
901                spec: spec.to_owned(),
902            });
903        };
904        if let Some(mapped) = map_through_remote_push_refspec(config, remote, &matched.0) {
905            let item = parse_push_refspec(&mapped).map_err(|e| PushPlanError::InvalidRefspec {
906                spec: mapped.clone(),
907                reason: e.to_string(),
908            })?;
909            return resolve_parsed_refspec(repo, &item, ctx, local);
910        }
911        let item = RefspecItem {
912            force: false,
913            src: Some(matched.0.clone()),
914            dst: Some(matched.0.clone()),
915            ..Default::default()
916        };
917        return push_spec_from_parsed_item(repo, &item, ctx, matched);
918    }
919    if count == 0 {
920        // Treat as explicit refspec string (Git refspec_append).
921        let item = parse_push_refspec(spec).map_err(|e| PushPlanError::InvalidRefspec {
922            spec: spec.to_owned(),
923            reason: e.to_string(),
924        })?;
925        return resolve_parsed_refspec(repo, &item, ctx, local);
926    }
927    Err(PushPlanError::SrcRefspecAmbiguous {
928        spec: spec.to_owned(),
929    })
930}
931
932fn map_through_remote_push_refspec(
933    config: &ConfigSet,
934    remote: &str,
935    local_full_ref: &str,
936) -> Option<String> {
937    for spec in remote_push_refspec_strings(config, remote) {
938        let Ok(item) = parse_push_refspec(&spec) else {
939            continue;
940        };
941        if item.negative || item.matching {
942            continue;
943        }
944        if let Some(dst) = refspec_map_src(&item, local_full_ref) {
945            let force = if item.force { "+" } else { "" };
946            return Some(format!("{force}{local_full_ref}:{dst}"));
947        }
948    }
949    None
950}
951
952fn expand_configured_push_refspec(
953    repo: &Repository,
954    spec: String,
955    ctx: &PushPlanContext,
956    local: &LocalPushRefs,
957) -> PlanResult<Vec<PushRefSpec>> {
958    let item = parse_push_refspec(&spec).map_err(|e| PushPlanError::InvalidRefspec {
959        spec: spec.clone(),
960        reason: e.to_string(),
961    })?;
962    if item.matching {
963        let mut out = Vec::new();
964        for (name, oid) in local.heads() {
965            if !ctx.remote_ref_names.contains(name) {
966                continue;
967            }
968            out.push(push_spec(
969                Some(*oid),
970                Some(name.clone()),
971                name.clone(),
972                item.force,
973                false,
974            ));
975        }
976        return Ok(out);
977    }
978    if item.pattern {
979        let prefix = item.src.as_deref().unwrap_or("");
980        let refs = if prefix.starts_with("refs/tags/") {
981            local.tags()
982        } else {
983            local.heads()
984        };
985        let mut out = Vec::new();
986        for (name, oid) in refs {
987            if let Some(dst) = refspec_map_src(&item, name) {
988                out.push(push_spec(
989                    Some(*oid),
990                    Some(name.clone()),
991                    dst,
992                    item.force,
993                    false,
994                ));
995            }
996        }
997        return Ok(out);
998    }
999    resolve_parsed_refspec(repo, &item, ctx, local)
1000}
1001
1002fn resolve_parsed_refspec(
1003    repo: &Repository,
1004    item: &RefspecItem,
1005    ctx: &PushPlanContext,
1006    local: &LocalPushRefs,
1007) -> PlanResult<Vec<PushRefSpec>> {
1008    if item.negative {
1009        return Ok(Vec::new());
1010    }
1011    if item.matching {
1012        return expand_configured_push_refspec(
1013            repo,
1014            if item.force {
1015                "+:".to_owned()
1016            } else {
1017                ":".to_owned()
1018            },
1019            ctx,
1020            local,
1021        );
1022    }
1023
1024    let src_empty = item.src.as_deref().is_some_and(|s| s.is_empty());
1025    let src_name = item.src.as_deref().unwrap_or("");
1026
1027    if src_empty || (src_name.is_empty() && item.dst.is_some()) {
1028        let dst = item
1029            .dst
1030            .as_ref()
1031            .ok_or_else(|| PushPlanError::InvalidRefspec {
1032                spec: String::new(),
1033                reason: "delete refspec missing destination".into(),
1034            })?;
1035        let resolved_dst = resolve_dst_name(repo, dst, None, ctx)?;
1036        return Ok(vec![push_spec(None, None, resolved_dst, item.force, true)]);
1037    }
1038
1039    let src_oid = if item.exact_sha1 {
1040        ObjectId::from_hex(src_name).map_err(|_| PushPlanError::UnknownRevision {
1041            spec: src_name.to_owned(),
1042        })?
1043    } else if src_name.is_empty() {
1044        resolve_src_ref(repo, "HEAD")?
1045    } else {
1046        match resolve_src_ref(repo, src_name) {
1047            Ok(oid) => oid,
1048            Err(_) => match local.count_refname_match(src_name) {
1049                0 => {
1050                    return Err(PushPlanError::SrcRefspecNoMatch {
1051                        spec: src_name.to_owned(),
1052                    });
1053                }
1054                1 => {
1055                    let Some((name, oid)) = local.refname_match(src_name) else {
1056                        return Err(PushPlanError::SrcRefspecNoMatch {
1057                            spec: src_name.to_owned(),
1058                        });
1059                    };
1060                    let dst = resolve_dst_for_src(repo, item, &name, ctx)?;
1061                    return Ok(vec![push_spec(
1062                        Some(oid),
1063                        Some(name.clone()),
1064                        dst,
1065                        item.force,
1066                        false,
1067                    )]);
1068                }
1069                _ => {
1070                    return Err(PushPlanError::SrcRefspecAmbiguous {
1071                        spec: src_name.to_owned(),
1072                    });
1073                }
1074            },
1075        }
1076    };
1077
1078    let known_src = if item.exact_sha1
1079        || src_name.is_empty()
1080        || src_name.eq_ignore_ascii_case("HEAD")
1081        || src_name == "@"
1082    {
1083        None
1084    } else {
1085        local.refname_match(src_name)
1086    };
1087    let src_full = if item.exact_sha1 {
1088        None
1089    } else if let Some((name, _)) = &known_src {
1090        Some(name.clone())
1091    } else {
1092        Some(full_src_ref_name(repo, src_name, local)?)
1093    };
1094    let src_label = src_full.clone();
1095    let dst = resolve_dst_for_src(repo, item, src_label.as_deref().unwrap_or(src_name), ctx)?;
1096    let mut spec = push_spec(Some(src_oid), src_label, dst, item.force, false);
1097    if matches!(src_name, "HEAD" | "@") {
1098        spec.hook_src_ref = Some("HEAD".to_owned());
1099    }
1100    Ok(vec![spec])
1101}
1102
1103fn push_spec_from_parsed_item(
1104    repo: &Repository,
1105    item: &RefspecItem,
1106    ctx: &PushPlanContext,
1107    known_src: (String, ObjectId),
1108) -> PlanResult<Vec<PushRefSpec>> {
1109    let src_name = item.src.as_deref().unwrap_or("");
1110    let src_oid = known_src.1;
1111
1112    let src_full = if item.exact_sha1 {
1113        None
1114    } else {
1115        Some(known_src.0.clone())
1116    };
1117    let src_label = src_full.clone();
1118    let dst = resolve_dst_for_src(repo, item, src_label.as_deref().unwrap_or(src_name), ctx)?;
1119    let mut spec = push_spec(Some(src_oid), src_label, dst, item.force, false);
1120    if matches!(src_name, "HEAD" | "@") {
1121        spec.hook_src_ref = Some("HEAD".to_owned());
1122    }
1123    Ok(vec![spec])
1124}
1125
1126fn resolve_dst_for_src(
1127    repo: &Repository,
1128    item: &RefspecItem,
1129    src_full: &str,
1130    ctx: &PushPlanContext,
1131) -> PlanResult<String> {
1132    if let Some(dst) = &item.dst {
1133        if dst.is_empty() {
1134            return Err(PushPlanError::InvalidRefspec {
1135                spec: String::new(),
1136                reason: "empty destination".into(),
1137            });
1138        }
1139        return resolve_dst_name(repo, dst, Some(src_full), ctx);
1140    }
1141    // Missing dst: use same ref name on remote (symref target for branches).
1142    if src_full.starts_with("refs/heads/") {
1143        return Ok(src_full.to_owned());
1144    }
1145    if src_full.starts_with("refs/tags/") {
1146        return Ok(src_full.to_owned());
1147    }
1148    Ok(src_full.to_owned())
1149}
1150
1151fn resolve_dst_name(
1152    _repo: &Repository,
1153    dst: &str,
1154    src_full: Option<&str>,
1155    ctx: &PushPlanContext,
1156) -> PlanResult<String> {
1157    if dst.starts_with("refs/") {
1158        return Ok(dst.to_owned());
1159    }
1160    let remote_list = remote_ref_list(ctx);
1161    match count_refname_match(dst, &remote_list) {
1162        0 => {}
1163        1 => {
1164            let Some((name, _)) = refname_match(dst, &remote_list) else {
1165                return Err(PushPlanError::InvalidRefspec {
1166                    spec: dst.to_owned(),
1167                    reason: "remote ref match failed".into(),
1168                });
1169            };
1170            return Ok(name);
1171        }
1172        _ => {
1173            return Err(PushPlanError::DstRefspecAmbiguous {
1174                spec: dst.to_owned(),
1175            });
1176        }
1177    }
1178    if let Some(src) = src_full {
1179        if let Some(guessed) = guess_qualified_dst(src, dst) {
1180            return Ok(guessed);
1181        }
1182    }
1183    Ok(format!("refs/heads/{dst}"))
1184}
1185
1186fn remote_ref_list(ctx: &PushPlanContext) -> Vec<(String, ObjectId)> {
1187    ctx.remote_ref_names
1188        .iter()
1189        .filter_map(|n| {
1190            // oid unused for matching; use zero placeholder.
1191            ObjectId::from_hex("0".repeat(40).as_str())
1192                .ok()
1193                .map(|z| (n.clone(), z))
1194        })
1195        .collect()
1196}
1197
1198fn guess_qualified_dst(src_full: &str, short_dst: &str) -> Option<String> {
1199    if src_full.starts_with("refs/heads/") {
1200        Some(format!("refs/heads/{short_dst}"))
1201    } else if src_full.starts_with("refs/tags/") {
1202        Some(format!("refs/tags/{short_dst}"))
1203    } else {
1204        None
1205    }
1206}
1207
1208/// Fill `force`, `expected_old`, and `expect_absent` on planned specs from [`ForceMode`].
1209fn apply_force_mode(
1210    repo: &Repository,
1211    config: &ConfigSet,
1212    remote: &str,
1213    specs: &mut [PushRefSpec],
1214    mode: &ForceMode,
1215) -> PlanResult<()> {
1216    match mode {
1217        ForceMode::None => {}
1218        ForceMode::Force => {
1219            for spec in specs.iter_mut() {
1220                spec.force = true;
1221                spec.expected_old = None;
1222                spec.expect_absent = false;
1223            }
1224        }
1225        ForceMode::Lease(LeaseExpectation::Tracking) => {
1226            for spec in specs.iter_mut() {
1227                apply_tracking_lease(repo, config, remote, spec);
1228            }
1229        }
1230        ForceMode::Lease(LeaseExpectation::Value(expected)) => {
1231            if specs.len() != 1 {
1232                return Err(PushPlanError::LeaseValueRequiresSingleRef);
1233            }
1234            for spec in specs.iter_mut() {
1235                spec.force = true;
1236                spec.expected_old = Some(*expected);
1237                spec.expect_absent = false;
1238            }
1239        }
1240    }
1241    Ok(())
1242}
1243
1244fn apply_tracking_lease(
1245    repo: &Repository,
1246    config: &ConfigSet,
1247    remote: &str,
1248    spec: &mut PushRefSpec,
1249) {
1250    spec.force = true;
1251    spec.expected_old = None;
1252    spec.expect_absent = false;
1253    if let Some(tracking) = remote_tracking_ref_for_remote_ref(config, remote, &spec.dst) {
1254        match refs::resolve_ref(repo.git_dir(), &tracking) {
1255            Ok(oid) => spec.expected_old = Some(oid),
1256            Err(_) => spec.expect_absent = true,
1257        }
1258    } else {
1259        spec.expect_absent = true;
1260    }
1261}
1262
1263fn push_spec(
1264    src: Option<ObjectId>,
1265    src_ref: Option<String>,
1266    dst: String,
1267    force: bool,
1268    delete: bool,
1269) -> PushRefSpec {
1270    PushRefSpec {
1271        src,
1272        src_ref,
1273        dst,
1274        force,
1275        delete,
1276        ..Default::default()
1277    }
1278}
1279
1280fn resolve_src_ref(repo: &Repository, spec: &str) -> PlanResult<ObjectId> {
1281    resolve_revision_for_verify(repo, spec).map_err(|_| PushPlanError::UnknownRevision {
1282        spec: spec.to_owned(),
1283    })
1284}
1285
1286fn full_src_ref_name(repo: &Repository, spec: &str, local: &LocalPushRefs) -> PlanResult<String> {
1287    if spec.eq_ignore_ascii_case("HEAD") {
1288        if let Ok(HeadState::Branch { short_name, .. }) = resolve_head(repo.git_dir()) {
1289            return Ok(format!("refs/heads/{short_name}"));
1290        }
1291    }
1292    if spec.starts_with("refs/") {
1293        return Ok(spec.to_owned());
1294    }
1295    if let Some((name, _)) = local.refname_match(spec) {
1296        return Ok(name);
1297    }
1298    Ok(spec.to_owned())
1299}
1300
1301/// Local heads and tags loaded once for refspec planning.
1302struct LocalPushRefs {
1303    all: Vec<(String, ObjectId)>,
1304    by_full: HashMap<String, ObjectId>,
1305    by_short: HashMap<String, Vec<(String, ObjectId)>>,
1306    heads: Vec<(String, ObjectId)>,
1307    tags: Vec<(String, ObjectId)>,
1308}
1309
1310impl LocalPushRefs {
1311    fn load(repo: &Repository) -> PlanResult<Self> {
1312        let heads = list_local_refs(repo.git_dir(), "refs/heads/")?;
1313        let tags = list_local_refs(repo.git_dir(), "refs/tags/")?;
1314        let mut all = Vec::with_capacity(heads.len() + tags.len());
1315        let mut by_full = HashMap::with_capacity(heads.len() + tags.len());
1316        let mut by_short: HashMap<String, Vec<(String, ObjectId)>> = HashMap::new();
1317        for entry in heads.iter().chain(tags.iter()) {
1318            all.push(entry.clone());
1319            by_full.insert(entry.0.clone(), entry.1);
1320            if let Some(short) = entry
1321                .0
1322                .strip_prefix("refs/heads/")
1323                .or_else(|| entry.0.strip_prefix("refs/tags/"))
1324            {
1325                by_short
1326                    .entry(short.to_owned())
1327                    .or_default()
1328                    .push(entry.clone());
1329            }
1330        }
1331        Ok(Self {
1332            all,
1333            by_full,
1334            by_short,
1335            heads,
1336            tags,
1337        })
1338    }
1339
1340    fn heads(&self) -> &[(String, ObjectId)] {
1341        &self.heads
1342    }
1343
1344    fn tags(&self) -> &[(String, ObjectId)] {
1345        &self.tags
1346    }
1347
1348    fn count_refname_match(&self, pattern: &str) -> usize {
1349        if (pattern.starts_with("refs/heads/") || pattern.starts_with("refs/tags/"))
1350            && self.by_full.contains_key(pattern)
1351        {
1352            return 1;
1353        }
1354        if let Some(entries) = self.by_short.get(pattern) {
1355            return count_refname_match(pattern, entries);
1356        }
1357        count_refname_match(pattern, &self.all)
1358    }
1359
1360    fn refname_match(&self, pattern: &str) -> Option<(String, ObjectId)> {
1361        if pattern.starts_with("refs/heads/") || pattern.starts_with("refs/tags/") {
1362            if let Some(oid) = self.by_full.get(pattern) {
1363                return Some((pattern.to_owned(), *oid));
1364            }
1365        }
1366        if let Some(entries) = self.by_short.get(pattern) {
1367            return refname_match(pattern, entries);
1368        }
1369        refname_match(pattern, &self.all)
1370    }
1371}
1372
1373fn list_local_refs(git_dir: &Path, prefix: &str) -> PlanResult<Vec<(String, ObjectId)>> {
1374    refs::list_refs(git_dir, prefix).map_err(|e| PushPlanError::Message(e.to_string()))
1375}
1376
1377/// Port of Git `count_refspec_match` / `refname_match` for local refs.
1378fn count_refname_match(pattern: &str, refs: &[(String, ObjectId)]) -> usize {
1379    let mut strong = 0usize;
1380    let mut weak = 0usize;
1381    let patlen = pattern.len();
1382    for (name, _) in refs {
1383        if !refname_match_one(pattern, name) {
1384            continue;
1385        }
1386        let namelen = name.len();
1387        if namelen != patlen
1388            && patlen != namelen.saturating_sub(5)
1389            && !name.starts_with("refs/heads/")
1390            && !name.starts_with("refs/tags/")
1391        {
1392            weak += 1;
1393        } else {
1394            strong += 1;
1395        }
1396    }
1397    if strong == 0 {
1398        weak
1399    } else {
1400        strong
1401    }
1402}
1403
1404fn refname_match(pattern: &str, refs: &[(String, ObjectId)]) -> Option<(String, ObjectId)> {
1405    let mut strong: Option<(String, ObjectId)> = None;
1406    let mut weak: Option<(String, ObjectId)> = None;
1407    let patlen = pattern.len();
1408    for (name, oid) in refs {
1409        if !refname_match_one(pattern, name) {
1410            continue;
1411        }
1412        let namelen = name.len();
1413        if namelen != patlen
1414            && patlen != namelen.saturating_sub(5)
1415            && !name.starts_with("refs/heads/")
1416            && !name.starts_with("refs/tags/")
1417        {
1418            weak = Some((name.clone(), *oid));
1419        } else {
1420            strong = Some((name.clone(), *oid));
1421        }
1422    }
1423    strong.or(weak)
1424}
1425
1426fn refname_match_one(pattern: &str, refname: &str) -> bool {
1427    if pattern == refname {
1428        return true;
1429    }
1430    if refname.ends_with(pattern) {
1431        // `main` matches `refs/heads/main`
1432        if refname == format!("refs/heads/{pattern}") || refname == format!("refs/tags/{pattern}") {
1433            return true;
1434        }
1435        if pattern.len() + 5 == refname.len() && refname.starts_with("refs/heads/") {
1436            return refname.strip_prefix("refs/heads/") == Some(pattern);
1437        }
1438        if pattern.len() + 5 == refname.len() && refname.starts_with("refs/tags/") {
1439            return refname.strip_prefix("refs/tags/") == Some(pattern);
1440        }
1441    }
1442    false
1443}
1444
1445fn refspec_map_src(item: &RefspecItem, src_ref: &str) -> Option<String> {
1446    let src_pat = item.src.as_deref()?;
1447    let dst_pat = item.dst.as_deref()?;
1448    match_refname_with_pattern(src_pat, src_ref, dst_pat)
1449}
1450
1451/// Whether a planned push ref should be dropped by a configured `^` refspec.
1452fn push_spec_excluded_by_negative(item: &RefspecItem, spec: &PushRefSpec) -> bool {
1453    if !item.negative {
1454        return false;
1455    }
1456    let Some(pat) = item.src.as_deref() else {
1457        return false;
1458    };
1459    let local = spec.src_ref.as_deref().or_else(|| {
1460        spec.dst
1461            .starts_with("refs/heads/")
1462            .then_some(spec.dst.as_str())
1463    });
1464    let Some(local) = local else {
1465        return false;
1466    };
1467    if item.pattern {
1468        return match_refname_with_pattern(pat, local, pat).is_some();
1469    }
1470    pat == local || refname_match_one(pat, local)
1471}
1472
1473fn match_refname_with_pattern(pattern: &str, refname: &str, replacement: &str) -> Option<String> {
1474    match (pattern.find('*'), replacement.find('*')) {
1475        (Some(pstar), Some(vstar)) => {
1476            let kprefix = &pattern[..pstar];
1477            let ksuffix = &pattern[pstar + 1..];
1478            if !refname.starts_with(kprefix) || !refname.ends_with(ksuffix) {
1479                return None;
1480            }
1481            if refname.len() < kprefix.len() + ksuffix.len() {
1482                return None;
1483            }
1484            let middle = &refname[kprefix.len()..refname.len() - ksuffix.len()];
1485            Some(format!(
1486                "{}{}{}",
1487                &replacement[..vstar],
1488                middle,
1489                &replacement[vstar + 1..]
1490            ))
1491        }
1492        (None, None) if pattern == refname => Some(replacement.to_owned()),
1493        _ => None,
1494    }
1495}
1496
1497fn delete_branch_label(specs: &[PushRefSpec]) -> Option<String> {
1498    if specs.len() != 1 {
1499        return None;
1500    }
1501    specs[0].dst.strip_prefix("refs/heads/").map(str::to_owned)
1502}
1503
1504fn branch_label_from_specs(_repo: &Repository, specs: &[PushRefSpec]) -> Option<String> {
1505    if specs.len() != 1 || specs[0].delete {
1506        return None;
1507    }
1508    let dst = &specs[0].dst;
1509    dst.strip_prefix("refs/heads/")
1510        .or_else(|| dst.strip_prefix("refs/tags/"))
1511        .map(str::to_owned)
1512}
1513
1514#[cfg(test)]
1515mod tests {
1516    use super::*;
1517    use crate::config::environment::Environment;
1518    use crate::repo::{InitOptions, Repository};
1519
1520    fn init_repo() -> (tempfile::TempDir, Repository) {
1521        let tmp = tempfile::tempdir().expect("tempdir");
1522        let repo = Repository::init(tmp.path(), &InitOptions::default()).expect("init");
1523        (tmp, repo)
1524    }
1525
1526    #[test]
1527    fn push_remote_precedence_pushremote_over_remote() {
1528        let (_tmp, repo) = init_repo();
1529        let git_dir = repo.git_dir();
1530        std::fs::write(
1531            git_dir.join("config"),
1532            r#"[remote "origin"]
1533	url = /tmp/o.git
1534[remote "upstream"]
1535	url = /tmp/u.git
1536[branch "main"]
1537	remote = origin
1538	pushRemote = upstream
1539"#,
1540        )
1541        .expect("write config");
1542        std::fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").expect("write HEAD");
1543        let config = ConfigSet::load(&Environment::empty(), Some(git_dir), true).expect("config");
1544        let remote = configured_remote_for_push(&repo, &config, None).expect("remote");
1545        assert_eq!(remote, "upstream");
1546    }
1547
1548    #[test]
1549    fn map_through_skips_malformed_remote_push_refspec() {
1550        let (_tmp, repo) = init_repo();
1551        let git_dir = repo.git_dir();
1552        std::fs::write(
1553            git_dir.join("config"),
1554            r#"[remote "origin"]
1555	url = /tmp/o.git
1556	push = not a valid refspec at all
1557	push = refs/heads/*:refs/remotes/origin/*
1558"#,
1559        )
1560        .expect("write config");
1561        std::fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").expect("write HEAD");
1562        let config = ConfigSet::load(&Environment::empty(), Some(git_dir), true).expect("config");
1563        let mapped = map_through_remote_push_refspec(&config, "origin", "refs/heads/main");
1564        assert_eq!(
1565            mapped.as_deref(),
1566            Some("refs/heads/main:refs/remotes/origin/main")
1567        );
1568    }
1569
1570    #[test]
1571    fn force_with_lease_tracking_sets_expected_old() {
1572        let (_tmp, repo) = init_repo();
1573        let git_dir = repo.git_dir();
1574        std::fs::write(
1575            git_dir.join("config"),
1576            r#"[remote "origin"]
1577	url = /tmp/o.git
1578	fetch = +refs/heads/*:refs/remotes/origin/*
1579[branch "main"]
1580	remote = origin
1581	merge = refs/heads/main
1582"#,
1583        )
1584        .expect("config");
1585        std::fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").expect("HEAD");
1586        let oid = ObjectId::from_hex("a".repeat(40).as_str()).expect("oid");
1587        std::fs::create_dir_all(git_dir.join("refs/heads")).expect("heads dir");
1588        std::fs::write(
1589            git_dir.join("refs/heads/main"),
1590            format!("{}\n", oid.to_hex()),
1591        )
1592        .expect("main");
1593        std::fs::create_dir_all(git_dir.join("refs/remotes/origin")).expect("rt dir");
1594        std::fs::write(
1595            git_dir.join("refs/remotes/origin/main"),
1596            format!("{}\n", oid.to_hex()),
1597        )
1598        .expect("tracking");
1599        let config = ConfigSet::load(&Environment::empty(), Some(git_dir), true).expect("config");
1600        let ctx = PushPlanContext::default();
1601        let resolved = resolve_push(
1602            &repo,
1603            &config,
1604            &PushRequest {
1605                force: ForceMode::Lease(LeaseExpectation::Tracking),
1606                ..Default::default()
1607            },
1608            &ctx,
1609        )
1610        .expect("resolve");
1611        assert_eq!(resolved.specs.len(), 1);
1612        assert_eq!(resolved.specs[0].expected_old, Some(oid));
1613        assert!(resolved.specs[0].force);
1614    }
1615
1616    #[test]
1617    fn negative_push_refspec_filters_planned_spec() {
1618        let item = parse_push_refspec("^refs/heads/private").expect("parse");
1619        let kept = push_spec(
1620            Some(ObjectId::from_hex("0".repeat(40).as_str()).expect("oid")),
1621            Some("refs/heads/main".into()),
1622            "refs/heads/main".into(),
1623            false,
1624            false,
1625        );
1626        let dropped = push_spec(
1627            Some(ObjectId::from_hex("1".repeat(40).as_str()).expect("oid")),
1628            Some("refs/heads/private".into()),
1629            "refs/heads/private".into(),
1630            false,
1631            false,
1632        );
1633        assert!(!push_spec_excluded_by_negative(&item, &kept));
1634        assert!(push_spec_excluded_by_negative(&item, &dropped));
1635    }
1636
1637    fn config_from(text: &str) -> ConfigSet {
1638        let file = crate::config::ConfigFile::parse(
1639            Path::new("config"),
1640            text,
1641            crate::config::ConfigScope::Local,
1642        )
1643        .expect("parse config");
1644        let mut config = ConfigSet::new();
1645        config.merge(&file);
1646        config
1647    }
1648
1649    fn strings(items: &[&str]) -> Vec<String> {
1650        items.iter().map(|s| (*s).to_owned()).collect()
1651    }
1652
1653    #[test]
1654    fn push_invocation_remote_flag_keeps_every_positional_as_refspec() {
1655        let config = ConfigSet::new();
1656        let invocation =
1657            resolve_push_invocation(&config, Some("upstream"), &strings(&["origin", "main"]))
1658                .expect("invocation");
1659        assert_eq!(
1660            invocation,
1661            PushInvocation {
1662                remote: Some("upstream".into()),
1663                refspecs: strings(&["origin", "main"]),
1664            }
1665        );
1666    }
1667
1668    #[test]
1669    fn push_invocation_without_positionals_uses_default_remote() {
1670        let invocation = resolve_push_invocation(&ConfigSet::new(), None, &[]).expect("invocation");
1671        assert_eq!(invocation, PushInvocation::default());
1672    }
1673
1674    #[test]
1675    fn push_invocation_first_positional_is_configured_remote() {
1676        let config = config_from("[remote \"upstream\"]\n\turl = /tmp/u.git\n");
1677        for positionals in [strings(&["upstream"]), strings(&["upstream", "a", "b:c"])] {
1678            let invocation =
1679                resolve_push_invocation(&config, None, &positionals).expect("invocation");
1680            assert_eq!(invocation.remote.as_deref(), Some("upstream"));
1681            assert_eq!(invocation.refspecs, positionals[1..]);
1682        }
1683    }
1684
1685    #[test]
1686    fn push_invocation_first_positional_is_literal_url_or_path() {
1687        let config = ConfigSet::new();
1688        for remote in [
1689            "https://example.com/r",
1690            "../sibling.git",
1691            "/srv/repo",
1692            "../srv",
1693            "./srv",
1694            "./../srv",
1695            "../deep/nb3",
1696            ".",
1697            "..",
1698        ] {
1699            let invocation = resolve_push_invocation(&config, None, &strings(&[remote, "main"]))
1700                .expect("invocation");
1701            assert_eq!(invocation.remote.as_deref(), Some(remote));
1702            assert_eq!(invocation.refspecs, strings(&["main"]));
1703        }
1704    }
1705
1706    #[test]
1707    fn push_invocation_single_unknown_positional_is_a_refspec() {
1708        let config = config_from("[remote \"blank\"]\n\turl =   \n");
1709        for token in ["main", "HEAD:refs/heads/x", "blank"] {
1710            let invocation =
1711                resolve_push_invocation(&config, None, &strings(&[token])).expect("invocation");
1712            assert_eq!(invocation.remote, None, "{token}");
1713            assert_eq!(invocation.refspecs, strings(&[token]));
1714        }
1715    }
1716
1717    #[test]
1718    fn push_without_any_remote_is_no_remote() {
1719        let (_tmp, repo) = init_repo();
1720        assert_eq!(
1721            configured_remote_for_push(&repo, &ConfigSet::new(), None),
1722            Err(PushPlanError::NoRemote)
1723        );
1724        assert_eq!(PushPlanError::NoRemote.to_string(), "no remote to push to");
1725        assert_eq!(
1726            load_push_plan_context(&repo, &ConfigSet::new(), None, None).map(|_| ()),
1727            Err(PushPlanError::NoRemote)
1728        );
1729        let named = configured_remote_for_push(&repo, &ConfigSet::new(), Some("upstream"));
1730        assert_eq!(named.as_deref(), Ok("upstream"));
1731        let url_less_origin = config_from("[remote \"origin\"]\n\tpushurl = /tmp/o.git\n");
1732        assert_eq!(
1733            configured_remote_for_push(&repo, &url_less_origin, None).as_deref(),
1734            Ok("origin")
1735        );
1736    }
1737
1738    #[test]
1739    fn push_invocation_unknown_remote_before_refspecs_is_rejected() {
1740        let err = resolve_push_invocation(&ConfigSet::new(), None, &strings(&["nosuch", "main"]))
1741            .expect_err("unknown remote");
1742        assert_eq!(err, PushPlanError::UnknownRemote("nosuch".into()));
1743    }
1744
1745    #[test]
1746    fn owner_project_shorthand_expands_to_host_url() {
1747        let config = config_from("[remote \"team/app\"]\n\turl = /srv/app.git\n");
1748        let expand = |args: &[&str]| {
1749            let args: Vec<String> = args.iter().map(|s| (*s).to_owned()).collect();
1750            expand_repository_shorthand(&config, &args, "https://grithub.dev/")
1751        };
1752        assert_eq!(
1753            expand(&["me/proj", "main"]).as_deref(),
1754            Some("https://grithub.dev/me/proj.git")
1755        );
1756        // A lone positional is a refspec, not a repository.
1757        assert_eq!(expand(&["topic/x"]), None);
1758        // Configured remotes, URLs and paths are left alone.
1759        for literal in [
1760            "team/app",
1761            "origin",
1762            "./a/b",
1763            "a/b/c",
1764            "x/y.git",
1765            "https://h/a/b",
1766        ] {
1767            assert_eq!(expand(&[literal, "main"]), None, "{literal}");
1768        }
1769    }
1770
1771    #[test]
1772    fn open_push_destination_reads_config_or_treats_literal_as_url() {
1773        let config = config_from(
1774            "[remote \"origin\"]\n\turl = /tmp/o.git\n[remote \"nourl\"]\n\tfetch = +refs/heads/*:refs/remotes/nourl/*\n",
1775        );
1776        let origin = open_push_destination(&config, "origin").expect("origin");
1777        assert_eq!(origin.name.as_deref(), Some("origin"));
1778        for path in ["/srv/other.git", "../srv", "./srv"] {
1779            let literal = open_push_destination(&config, path).expect("literal");
1780            assert_eq!(literal.name, None, "{path}");
1781        }
1782        assert!(matches!(
1783            open_push_destination(&config, "nourl"),
1784            Err(RemoteError::NoUrl { remote: Some(name) }) if name == "nourl"
1785        ));
1786    }
1787
1788    #[test]
1789    fn plan_context_from_advertisement_skips_peeled_entries() {
1790        let oid = |c: &str| ObjectId::from_hex(&c.repeat(40)).expect("oid");
1791        let remote_ref = |name: &str, hex: &str| RemoteRef {
1792            name: name.to_owned(),
1793            oid: oid(hex),
1794            symref_target: None,
1795        };
1796        let ctx = PushPlanContext::from_advertised_refs([
1797            remote_ref("HEAD", "1"),
1798            remote_ref("refs/heads/main", "1"),
1799            remote_ref("refs/heads/topic", "2"),
1800            remote_ref("refs/tags/v1", "3"),
1801            remote_ref("refs/tags/v1^{}", "4"),
1802        ]);
1803        let mut names: Vec<_> = ctx.remote_ref_names.iter().map(String::as_str).collect();
1804        names.sort_unstable();
1805        assert_eq!(
1806            names,
1807            [
1808                "HEAD",
1809                "refs/heads/main",
1810                "refs/heads/topic",
1811                "refs/tags/v1"
1812            ]
1813        );
1814        assert_eq!(ctx.remote_branch_tips, HashSet::from([oid("1"), oid("2")]));
1815        assert_eq!(
1816            ctx.remote_tips,
1817            Some(vec![oid("1"), oid("1"), oid("2"), oid("3")])
1818        );
1819    }
1820
1821    #[test]
1822    fn plan_context_from_empty_advertisement_knows_remote_has_no_tips() {
1823        let ctx = PushPlanContext::from_advertised_refs([]);
1824        assert_eq!(ctx.remote_tips, Some(Vec::new()));
1825    }
1826
1827    #[test]
1828    fn plan_context_is_empty_for_unconfigured_remote() {
1829        let (_tmp, repo) = init_repo();
1830        let ctx = load_push_plan_context(&repo, &ConfigSet::new(), Some("nosuch"), None)
1831            .expect("context");
1832        assert!(ctx.remote_ref_names.is_empty());
1833        assert!(ctx.remote_branch_tips.is_empty());
1834        assert_eq!(ctx.remote_tips, None);
1835    }
1836
1837    #[test]
1838    fn plan_context_for_missing_path_assumes_no_remote_objects() {
1839        let (tmp, repo) = init_repo();
1840        let missing = tmp.path().join("missing.git");
1841        let missing = missing.to_str().expect("utf-8 path");
1842        let ctx =
1843            load_push_plan_context(&repo, &ConfigSet::new(), Some(missing), None).expect("context");
1844        assert!(ctx.remote_ref_names.is_empty());
1845        assert!(ctx.remote_tips.as_ref().is_none_or(Vec::is_empty));
1846    }
1847
1848    #[test]
1849    fn tag_push_branch_label_is_tag_name_not_current_branch() {
1850        let (_tmp, repo) = init_repo();
1851        let git_dir = repo.git_dir();
1852        std::fs::write(
1853            git_dir.join("config"),
1854            r#"[remote "origin"]
1855	url = /tmp/o.git
1856"#,
1857        )
1858        .expect("config");
1859        std::fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").expect("HEAD");
1860        let oid = ObjectId::from_hex("a".repeat(40).as_str()).expect("oid");
1861        std::fs::create_dir_all(git_dir.join("refs/heads")).expect("heads");
1862        std::fs::write(
1863            git_dir.join("refs/heads/main"),
1864            format!("{}\n", oid.to_hex()),
1865        )
1866        .expect("main");
1867        std::fs::create_dir_all(git_dir.join("refs/tags")).expect("tags");
1868        std::fs::write(git_dir.join("refs/tags/v1"), format!("{}\n", oid.to_hex())).expect("tag");
1869        let config = ConfigSet::load(&Environment::empty(), Some(git_dir), true).expect("config");
1870        let ctx = PushPlanContext::default();
1871        let resolved = resolve_push(
1872            &repo,
1873            &config,
1874            &PushRequest {
1875                remote: Some("origin".to_owned()),
1876                refspecs: vec!["v1".to_owned()],
1877                ..Default::default()
1878            },
1879            &ctx,
1880        )
1881        .expect("resolve tag push");
1882        assert_eq!(resolved.branch_label.as_deref(), Some("v1"));
1883        assert_eq!(resolved.kind, PushPlanKind::Refspecs);
1884        assert_eq!(resolved.specs[0].dst, "refs/tags/v1");
1885    }
1886
1887    fn delete_request(refspecs: &[&str]) -> PushRequest {
1888        PushRequest {
1889            remote: Some("origin".to_owned()),
1890            refspecs: strings(refspecs),
1891            delete_refs: true,
1892            ..Default::default()
1893        }
1894    }
1895
1896    #[test]
1897    fn delete_push_plans_every_plain_name() {
1898        let (_tmp, repo) = init_repo();
1899        let config = config_from("[remote \"origin\"]\n\turl = /tmp/o.git\n");
1900        let ctx = PushPlanContext {
1901            remote_ref_names: HashSet::from(["refs/tags/v1".to_owned()]),
1902            ..Default::default()
1903        };
1904        let resolved = resolve_push(
1905            &repo,
1906            &config,
1907            &delete_request(&["a", "refs/heads/b", "v1", "+c", "HEAD"]),
1908            &ctx,
1909        )
1910        .expect("resolve delete push");
1911        assert_eq!(resolved.kind, PushPlanKind::Delete);
1912        assert_eq!(resolved.branch_label, None);
1913        let dsts: Vec<_> = resolved.specs.iter().map(|s| s.dst.as_str()).collect();
1914        assert_eq!(
1915            dsts,
1916            [
1917                "refs/heads/a",
1918                "refs/heads/b",
1919                "refs/tags/v1",
1920                "refs/heads/+c",
1921                "refs/heads/HEAD"
1922            ]
1923        );
1924        assert!(resolved
1925            .specs
1926            .iter()
1927            .all(|s| s.delete && !s.force && s.src.is_none()));
1928    }
1929
1930    #[test]
1931    fn delete_push_unions_delete_list_with_refspecs() {
1932        let (_tmp, repo) = init_repo();
1933        let config = config_from("[remote \"origin\"]\n\turl = /tmp/o.git\n");
1934        let request = PushRequest {
1935            delete: strings(&["a"]),
1936            ..delete_request(&["b"])
1937        };
1938        let resolved = resolve_push(&repo, &config, &request, &PushPlanContext::default())
1939            .expect("resolve delete push");
1940        let dsts: Vec<_> = resolved.specs.iter().map(|s| s.dst.as_str()).collect();
1941        assert_eq!(dsts, ["refs/heads/a", "refs/heads/b"]);
1942        assert_eq!(
1943            resolve_push(
1944                &repo,
1945                &config,
1946                &PushRequest {
1947                    delete_refs: false,
1948                    ..request
1949                },
1950                &PushPlanContext::default(),
1951            )
1952            .map(|r| r.specs.len()),
1953            Ok(2)
1954        );
1955    }
1956
1957    #[test]
1958    fn delete_push_rejects_colon_refspec() {
1959        let (_tmp, repo) = init_repo();
1960        let config = config_from("[remote \"origin\"]\n\turl = /tmp/o.git\n");
1961        for spec in ["HEAD:refs/heads/release", ":b", "a:"] {
1962            let err = resolve_push(
1963                &repo,
1964                &config,
1965                &delete_request(&["ok", spec]),
1966                &PushPlanContext::default(),
1967            )
1968            .expect_err("colon refspec with --delete");
1969            assert_eq!(
1970                err,
1971                PushPlanError::DeleteNotPlainName {
1972                    spec: spec.to_owned()
1973                }
1974            );
1975            assert!(err.to_string().contains(spec), "{err}");
1976        }
1977    }
1978
1979    #[test]
1980    fn delete_push_without_refs_is_an_error() {
1981        let (_tmp, repo) = init_repo();
1982        let err = resolve_push(
1983            &repo,
1984            &ConfigSet::new(),
1985            &delete_request(&[]),
1986            &PushPlanContext::default(),
1987        )
1988        .expect_err("empty delete");
1989        assert_eq!(err, PushPlanError::DeleteWithoutRefs);
1990    }
1991
1992    #[test]
1993    fn many_shorthand_refspecs_resolve_local_heads() {
1994        let (_tmp, repo) = init_repo();
1995        let git_dir = repo.git_dir();
1996        std::fs::write(
1997            git_dir.join("config"),
1998            r#"[remote "origin"]
1999	url = /tmp/o.git
2000"#,
2001        )
2002        .expect("config");
2003        std::fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").expect("HEAD");
2004        let oid = ObjectId::from_hex("a".repeat(40).as_str()).expect("oid");
2005        std::fs::create_dir_all(git_dir.join("refs/heads")).expect("heads");
2006        std::fs::write(
2007            git_dir.join("refs/heads/main"),
2008            format!("{}\n", oid.to_hex()),
2009        )
2010        .expect("main");
2011        for i in 1..=500 {
2012            std::fs::write(
2013                git_dir.join(format!("refs/heads/b{i}")),
2014                format!("{}\n", oid.to_hex()),
2015            )
2016            .expect("branch");
2017        }
2018        let config = ConfigSet::load(&Environment::empty(), Some(git_dir), true).expect("config");
2019        let refspecs: Vec<String> = (1..=500).map(|i| format!("b{i}")).collect();
2020        let resolved = resolve_push(
2021            &repo,
2022            &config,
2023            &PushRequest {
2024                remote: Some("origin".to_owned()),
2025                refspecs,
2026                ..Default::default()
2027            },
2028            &PushPlanContext::default(),
2029        )
2030        .expect("resolve many shorthands");
2031        assert_eq!(resolved.specs.len(), 500);
2032        for (i, spec) in resolved.specs.iter().enumerate() {
2033            let n = i + 1;
2034            assert_eq!(spec.dst, format!("refs/heads/b{n}"));
2035            assert_eq!(spec.src, Some(oid));
2036            assert_eq!(
2037                spec.src_ref.as_deref(),
2038                Some(format!("refs/heads/b{n}").as_str())
2039            );
2040        }
2041    }
2042
2043    #[test]
2044    fn shorthand_ambiguous_head_and_tag_same_name() {
2045        let (_tmp, repo) = init_repo();
2046        let git_dir = repo.git_dir();
2047        std::fs::write(
2048            git_dir.join("config"),
2049            r#"[remote "origin"]
2050	url = /tmp/o.git
2051"#,
2052        )
2053        .expect("config");
2054        let oid = ObjectId::from_hex("b".repeat(40).as_str()).expect("oid");
2055        std::fs::create_dir_all(git_dir.join("refs/heads")).expect("heads");
2056        std::fs::create_dir_all(git_dir.join("refs/tags")).expect("tags");
2057        std::fs::write(
2058            git_dir.join("refs/heads/release"),
2059            format!("{}\n", oid.to_hex()),
2060        )
2061        .expect("head");
2062        std::fs::write(
2063            git_dir.join("refs/tags/release"),
2064            format!("{}\n", oid.to_hex()),
2065        )
2066        .expect("tag");
2067        let config = ConfigSet::load(&Environment::empty(), Some(git_dir), true).expect("config");
2068        let err = resolve_push(
2069            &repo,
2070            &config,
2071            &PushRequest {
2072                remote: Some("origin".to_owned()),
2073                refspecs: vec!["release".to_owned()],
2074                ..Default::default()
2075            },
2076            &PushPlanContext::default(),
2077        )
2078        .expect_err("ambiguous");
2079        assert_eq!(
2080            err,
2081            PushPlanError::SrcRefspecAmbiguous {
2082                spec: "release".into()
2083            }
2084        );
2085    }
2086
2087    #[test]
2088    fn shorthand_honors_remote_push_mapping() {
2089        let (_tmp, repo) = init_repo();
2090        let git_dir = repo.git_dir();
2091        std::fs::write(
2092            git_dir.join("config"),
2093            r#"[remote "origin"]
2094	url = /tmp/o.git
2095	push = refs/heads/main:refs/remotes/origin/main
2096"#,
2097        )
2098        .expect("config");
2099        let oid = ObjectId::from_hex("c".repeat(40).as_str()).expect("oid");
2100        std::fs::create_dir_all(git_dir.join("refs/heads")).expect("heads");
2101        std::fs::write(
2102            git_dir.join("refs/heads/main"),
2103            format!("{}\n", oid.to_hex()),
2104        )
2105        .expect("main");
2106        let config = ConfigSet::load(&Environment::empty(), Some(git_dir), true).expect("config");
2107        let resolved = resolve_push(
2108            &repo,
2109            &config,
2110            &PushRequest {
2111                remote: Some("origin".to_owned()),
2112                refspecs: vec!["main".to_owned()],
2113                ..Default::default()
2114            },
2115            &PushPlanContext::default(),
2116        )
2117        .expect("mapped shorthand");
2118        assert_eq!(resolved.specs.len(), 1);
2119        assert_eq!(resolved.specs[0].dst, "refs/remotes/origin/main");
2120        assert_eq!(resolved.specs[0].src, Some(oid));
2121    }
2122}