Skip to main content

git_meta_lib/
push.rs

1//! Push local metadata to a remote: serialize, push, and conflict resolution.
2//!
3//! This module implements the single-attempt push workflow and the
4//! conflict resolution step. The retry loop is intentionally left to
5//! the caller (CLI or other consumer) since retry policy is a UX concern.
6//!
7//! The public entry points are [`push_once()`] for a single push attempt
8//! and [`resolve_push_conflict()`] for fetching, materializing, and
9//! rebasing after a non-fast-forward rejection.
10
11use gix::prelude::ObjectIdExt;
12use gix::refs::transaction::PreviousValue;
13
14use crate::error::{Error, Result};
15use crate::git_utils;
16use crate::session::Session;
17
18/// Result of a single push attempt.
19///
20/// Contains all the information needed by a CLI or other consumer
21/// to report what happened, without performing any I/O itself.
22#[must_use]
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct PushOutput {
25    /// Whether the push succeeded (or was already up-to-date).
26    pub success: bool,
27    /// Whether the push was rejected as non-fast-forward.
28    pub non_fast_forward: bool,
29    /// Whether local and remote were already in sync (nothing to push).
30    pub up_to_date: bool,
31    /// The resolved remote name that was pushed to.
32    pub remote_name: String,
33    /// The remote refspec that was pushed to (e.g. `refs/meta/main`).
34    pub remote_ref: String,
35    /// The commit OID that was pushed (or attempted).
36    pub commit_oid: String,
37}
38
39/// Execute a single push attempt: serialize, then git push.
40///
41/// Does NOT retry on failure. Returns whether it succeeded or was
42/// rejected. The caller (CLI) implements retry policy.
43///
44/// # Parameters
45///
46/// - `session`: the gmeta session providing the repository, store, and config.
47/// - `remote`: optional remote name to push to. If `None`, the first
48///   configured metadata remote is used.
49/// - `now`: the current timestamp in milliseconds since the Unix epoch,
50///   used for the commit signature during serialization.
51///
52/// # Returns
53///
54/// A [`PushOutput`] indicating success or failure, whether the failure
55/// was a non-fast-forward rejection, and the commit OID that was pushed
56/// or attempted.
57///
58/// # Errors
59///
60/// Returns an error if serialization fails, the local ref cannot be read,
61/// or the push fails for a reason other than non-fast-forward rejection
62/// (in which case `success` is `false` and `non_fast_forward` is `false`).
63pub fn push_once(session: &Session, remote: Option<&str>, now: i64) -> Result<PushOutput> {
64    let repo = &session.repo;
65    let ns = session.namespace();
66
67    let remote_name = git_utils::resolve_meta_remote(repo, remote)?;
68    let local_ref = session.local_ref();
69    let remote_refspec = format!("refs/{ns}/main");
70
71    // Serialize local metadata to the local ref
72    let _ = crate::serialize::run(session, now)?;
73
74    // Verify we have something to push
75    if repo.find_reference(&local_ref).is_err() {
76        return Err(Error::Other(
77            "nothing to push (no local metadata ref)".into(),
78        ));
79    }
80
81    // Check if local ref already matches the remote ref (nothing new to push)
82    let remote_tracking_ref = format!("refs/{ns}/remotes/main");
83    let local_oid = repo
84        .find_reference(&local_ref)
85        .ok()
86        .and_then(|r| r.into_fully_peeled_id().ok())
87        .map(gix::Id::detach);
88    let remote_oid = repo
89        .find_reference(&remote_tracking_ref)
90        .ok()
91        .and_then(|r| r.into_fully_peeled_id().ok())
92        .map(gix::Id::detach);
93
94    if let (Some(local), Some(remote_id)) = (local_oid.as_ref(), remote_oid.as_ref()) {
95        if local == remote_id {
96            return Ok(PushOutput {
97                success: true,
98                non_fast_forward: false,
99                up_to_date: true,
100                remote_name,
101                remote_ref: remote_refspec,
102                commit_oid: local.to_string(),
103            });
104        }
105    }
106
107    let commit_oid_str = local_oid
108        .as_ref()
109        .map(ToString::to_string)
110        .unwrap_or_default();
111
112    // Attempt push
113    let push_refspec = format!("{local_ref}:{remote_refspec}");
114    let result = git_utils::run_git(repo, &["push", &remote_name, &push_refspec]);
115
116    match result {
117        Ok(_) => Ok(PushOutput {
118            success: true,
119            non_fast_forward: false,
120            up_to_date: false,
121            remote_name,
122            remote_ref: remote_refspec,
123            commit_oid: commit_oid_str,
124        }),
125        Err(e) => {
126            let err_msg = e.to_string();
127            let is_non_ff = err_msg.contains("non-fast-forward")
128                || err_msg.contains("rejected")
129                || err_msg.contains("fetch first");
130
131            if is_non_ff {
132                Ok(PushOutput {
133                    success: false,
134                    non_fast_forward: true,
135                    up_to_date: false,
136                    remote_name,
137                    remote_ref: remote_refspec,
138                    commit_oid: commit_oid_str,
139                })
140            } else {
141                Err(Error::GitCommand(format!("push failed: {err_msg}")))
142            }
143        }
144    }
145}
146
147/// After a failed push, fetch remote changes, materialize, re-serialize,
148/// and rebase local ref for clean fast-forward.
149///
150/// Call this between push retries. It fetches the latest remote data,
151/// hydrates tip blobs, materializes changes into the local store,
152/// re-serializes the merged data, and rebases the local ref on top of
153/// the remote tip so the next push is a clean fast-forward.
154///
155/// # Parameters
156///
157/// - `session`: the gmeta session providing the repository, store, and config.
158/// - `remote`: optional remote name. If `None`, the first configured
159///   metadata remote is used.
160/// - `now`: the current timestamp in milliseconds since the Unix epoch,
161///   used for database writes during materialization.
162///
163/// # Errors
164///
165/// Returns an error if fetch, materialization, serialization, or rebase fails.
166pub fn resolve_push_conflict(session: &Session, remote: Option<&str>, now: i64) -> Result<()> {
167    let repo = &session.repo;
168    let ns = session.namespace();
169
170    let remote_name = git_utils::resolve_meta_remote(repo, remote)?;
171    let local_ref = session.local_ref();
172    let remote_refspec = format!("refs/{ns}/main");
173    let remote_tracking_ref = format!("refs/{ns}/remotes/main");
174
175    // Fetch latest remote data
176    let fetch_refspec = format!("{remote_refspec}:{remote_tracking_ref}");
177    git_utils::run_git(repo, &["fetch", &remote_name, &fetch_refspec])?;
178
179    // Hydrate tip tree blobs so gix can read them
180    let short_ref = format!("{ns}/remotes/main");
181    git_utils::hydrate_tip_blobs(repo, &remote_name, &short_ref)?;
182
183    // Materialize the remote data (merge into local DB)
184    let _ = crate::materialize::run(session, None, now)?;
185
186    // Re-serialize with merged data
187    let _ = crate::serialize::run(session, now)?;
188
189    // Rewrite local ref as a single commit on top of the remote tip.
190    // This avoids merge commits in the pushed history — the spec
191    // requires that push always produces a single fast-forward commit.
192    rebase_local_on_remote(repo, &local_ref, &remote_tracking_ref)?;
193
194    Ok(())
195}
196
197/// Rewrite the local ref as a single non-merge commit whose parent is the
198/// remote tip and whose tree is the current local ref's tree.
199///
200/// This ensures the pushed history is always a clean fast-forward with
201/// no merge commits.
202fn rebase_local_on_remote(repo: &gix::Repository, local_ref: &str, remote_ref: &str) -> Result<()> {
203    let local_ref_obj = repo
204        .find_reference(local_ref)
205        .map_err(|e| Error::Other(format!("{e}")))?;
206    let local_oid = local_ref_obj
207        .into_fully_peeled_id()
208        .map_err(|e| Error::Other(format!("{e}")))?
209        .detach();
210    let local_commit_obj = local_oid
211        .attach(repo)
212        .object()
213        .map_err(|e| Error::Other(format!("{e}")))?
214        .into_commit();
215    let local_decoded = local_commit_obj
216        .decode()
217        .map_err(|e| Error::Other(format!("{e}")))?;
218
219    let remote_ref_obj = repo
220        .find_reference(remote_ref)
221        .map_err(|e| Error::Other(format!("{e}")))?;
222    let remote_oid = remote_ref_obj
223        .into_fully_peeled_id()
224        .map_err(|e| Error::Other(format!("{e}")))?
225        .detach();
226
227    // If the local commit is already a single-parent child of remote, nothing to do
228    let parent_ids: Vec<gix::ObjectId> = local_decoded.parents().collect();
229    if parent_ids.len() == 1 && parent_ids[0] == remote_oid {
230        return Ok(());
231    }
232
233    let tree_id = local_decoded.tree();
234    let message = local_decoded.message.to_owned();
235    let author_ref = local_decoded
236        .author()
237        .map_err(|e| Error::Other(format!("{e}")))?;
238
239    let commit = gix::objs::Commit {
240        message,
241        tree: tree_id,
242        author: gix::actor::Signature {
243            name: author_ref.name.into(),
244            email: author_ref.email.into(),
245            time: author_ref
246                .time()
247                .map_err(|e| Error::Other(format!("{e}")))?,
248        },
249        committer: gix::actor::Signature {
250            name: author_ref.name.into(),
251            email: author_ref.email.into(),
252            time: author_ref
253                .time()
254                .map_err(|e| Error::Other(format!("{e}")))?,
255        },
256        encoding: None,
257        parents: vec![remote_oid].into(),
258        extra_headers: Default::default(),
259    };
260
261    let new_oid = repo
262        .write_object(&commit)
263        .map_err(|e| Error::Other(format!("{e}")))?
264        .detach();
265    repo.reference(
266        local_ref,
267        new_oid,
268        PreviousValue::Any,
269        "git-meta: rebase for push",
270    )
271    .map_err(|e| Error::Other(format!("{e}")))?;
272
273    Ok(())
274}