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