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/// Progress event emitted during push preparation and conflict resolution.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum PushProgress {
42    /// Local and remote refs are being inspected.
43    CheckingLocalState,
44    /// Local SQLite metadata is being serialized to the local metadata ref.
45    Serializing,
46    /// The local metadata ref is already current enough to push.
47    SerializationSkipped,
48    /// The local metadata commit is being rewritten onto the tracked remote tip.
49    RebasingLocal,
50    /// The local metadata ref is being pushed to the remote.
51    Pushing {
52        /// The resolved remote name.
53        remote_name: String,
54        /// The local ref being pushed.
55        local_ref: String,
56        /// The remote ref receiving the push.
57        remote_ref: String,
58    },
59    /// Latest metadata is being fetched after a non-fast-forward rejection.
60    FetchingRemote {
61        /// The resolved remote name.
62        remote_name: String,
63        /// The remote metadata ref being fetched.
64        remote_ref: String,
65    },
66    /// Remote tip blobs are being hydrated so the metadata tree can be read.
67    HydratingRemoteTip,
68    /// Remote metadata is being materialized into the local store.
69    MaterializingRemote,
70    /// Merged local metadata is being serialized after conflict resolution.
71    SerializingMerged,
72    /// The merged local metadata ref is being rebased onto the remote tip.
73    RebasingMerged,
74}
75
76/// Execute a single push attempt: serialize, rewrite onto the tracked remote
77/// tip when needed, then git push.
78///
79/// Does NOT retry on failure. Returns whether it succeeded or was
80/// rejected. The caller (CLI) implements retry policy.
81///
82/// # Parameters
83///
84/// - `session`: the gmeta session providing the repository, store, and config.
85/// - `remote`: optional remote name to push to. If `None`, the first
86///   configured metadata remote is used.
87/// - `now`: the current timestamp in milliseconds since the Unix epoch,
88///   used for the commit signature during serialization.
89///
90/// # Returns
91///
92/// A [`PushOutput`] indicating success or failure, whether the failure
93/// was a non-fast-forward rejection, and the commit OID that was pushed
94/// or attempted.
95///
96/// # Errors
97///
98/// Returns an error if serialization fails, the local ref cannot be read,
99/// or the push fails for a reason other than non-fast-forward rejection
100/// (in which case `success` is `false` and `non_fast_forward` is `false`).
101pub fn push_once(session: &Session, remote: Option<&str>, now: i64) -> Result<PushOutput> {
102    push_once_with_progress(session, remote, now, |_| {})
103}
104
105/// Execute a single push attempt and report phase progress through a callback.
106///
107/// # Parameters
108///
109/// - `session`: the gmeta session providing the repository, store, and config.
110/// - `remote`: optional remote name to push to. If `None`, the first
111///   configured metadata remote is used.
112/// - `now`: the current timestamp in milliseconds since the Unix epoch.
113/// - `progress`: callback invoked before long-running phases.
114///
115/// # Errors
116///
117/// Returns an error if serialization fails, the local ref cannot be read,
118/// or the push fails for a reason other than non-fast-forward rejection.
119pub fn push_once_with_progress(
120    session: &Session,
121    remote: Option<&str>,
122    now: i64,
123    mut progress: impl FnMut(PushProgress),
124) -> Result<PushOutput> {
125    let repo = &session.repo;
126    let ns = session.namespace();
127
128    let remote_name = git_utils::resolve_meta_remote(repo, remote)?;
129    let local_ref = session.local_ref();
130    let remote_refspec = format!("refs/{ns}/main");
131    let remote_tracking_ref = format!("refs/{ns}/remotes/main");
132
133    progress(PushProgress::CheckingLocalState);
134    let mut local_oid = peeled_ref_oid(repo, &local_ref);
135    let remote_oid = peeled_ref_oid(repo, &remote_tracking_ref);
136
137    if should_serialize_before_push(session, local_oid.as_ref(), remote_oid.as_ref())? {
138        progress(PushProgress::Serializing);
139        let _ = crate::serialize::run(session, now, false)?;
140        local_oid = peeled_ref_oid(repo, &local_ref);
141    } else {
142        progress(PushProgress::SerializationSkipped);
143    }
144
145    // Verify we have something to push
146    if local_oid.is_none() {
147        return Err(Error::Other(
148            "nothing to push (no local metadata ref)".into(),
149        ));
150    }
151
152    // Check if local ref already matches the remote ref (nothing new to push)
153    if let (Some(local), Some(remote_id)) = (local_oid.as_ref(), remote_oid.as_ref()) {
154        if local == remote_id {
155            return Ok(PushOutput {
156                success: true,
157                non_fast_forward: false,
158                up_to_date: true,
159                remote_name,
160                remote_ref: remote_refspec,
161                commit_oid: local.to_string(),
162            });
163        }
164
165        progress(PushProgress::RebasingLocal);
166        rebase_local_on_remote(repo, &local_ref, &remote_tracking_ref)?;
167        local_oid = repo
168            .find_reference(&local_ref)
169            .ok()
170            .and_then(|r| r.into_fully_peeled_id().ok())
171            .map(gix::Id::detach);
172    }
173
174    let commit_oid_str = local_oid
175        .as_ref()
176        .map(ToString::to_string)
177        .unwrap_or_default();
178
179    // Attempt push
180    let push_refspec = format!("{local_ref}:{remote_refspec}");
181    progress(PushProgress::Pushing {
182        remote_name: remote_name.clone(),
183        local_ref: local_ref.clone(),
184        remote_ref: remote_refspec.clone(),
185    });
186    let result = git_utils::run_git(repo, &["push", &remote_name, &push_refspec]);
187
188    match result {
189        Ok(_) => Ok(PushOutput {
190            success: true,
191            non_fast_forward: false,
192            up_to_date: false,
193            remote_name,
194            remote_ref: remote_refspec,
195            commit_oid: commit_oid_str,
196        }),
197        Err(e) => {
198            let err_msg = e.to_string();
199            let is_non_ff = err_msg.contains("non-fast-forward")
200                || err_msg.contains("rejected")
201                || err_msg.contains("fetch first");
202
203            if is_non_ff {
204                Ok(PushOutput {
205                    success: false,
206                    non_fast_forward: true,
207                    up_to_date: false,
208                    remote_name,
209                    remote_ref: remote_refspec,
210                    commit_oid: commit_oid_str,
211                })
212            } else {
213                Err(Error::GitCommand(format!("push failed: {err_msg}")))
214            }
215        }
216    }
217}
218
219fn peeled_ref_oid(repo: &gix::Repository, ref_name: &str) -> Option<gix::ObjectId> {
220    repo.find_reference(ref_name)
221        .ok()
222        .and_then(|r| r.into_fully_peeled_id().ok())
223        .map(gix::Id::detach)
224}
225
226fn should_serialize_before_push(
227    session: &Session,
228    local_oid: Option<&gix::ObjectId>,
229    remote_oid: Option<&gix::ObjectId>,
230) -> Result<bool> {
231    let Some(local) = local_oid else {
232        return Ok(true);
233    };
234
235    if remote_oid.is_some_and(|remote| remote == local) {
236        return Ok(true);
237    }
238
239    let Some(last_materialized) = session.store.get_last_materialized()? else {
240        return Ok(true);
241    };
242
243    Ok(!session
244        .store
245        .get_modified_since(last_materialized)?
246        .is_empty())
247}
248
249/// After a failed push, fetch remote changes, materialize, re-serialize,
250/// and rebase local ref for clean fast-forward.
251///
252/// Call this between push retries. It fetches the latest remote data,
253/// hydrates tip blobs, materializes changes into the local store,
254/// re-serializes the merged data, and rebases the local ref on top of
255/// the remote tip so the next push is a clean fast-forward.
256///
257/// # Parameters
258///
259/// - `session`: the gmeta session providing the repository, store, and config.
260/// - `remote`: optional remote name. If `None`, the first configured
261///   metadata remote is used.
262/// - `now`: the current timestamp in milliseconds since the Unix epoch,
263///   used for database writes during materialization.
264///
265/// # Errors
266///
267/// Returns an error if fetch, materialization, serialization, or rebase fails.
268pub fn resolve_push_conflict(session: &Session, remote: Option<&str>, now: i64) -> Result<()> {
269    resolve_push_conflict_with_progress(session, remote, now, |_| {})
270}
271
272/// Resolve a non-fast-forward push rejection and report phase progress.
273///
274/// # Parameters
275///
276/// - `session`: the gmeta session providing the repository, store, and config.
277/// - `remote`: optional remote name. If `None`, the first configured
278///   metadata remote is used.
279/// - `now`: the current timestamp in milliseconds since the Unix epoch.
280/// - `progress`: callback invoked before long-running phases.
281///
282/// # Errors
283///
284/// Returns an error if fetch, hydration, materialization, serialization, or
285/// rebase fails.
286pub fn resolve_push_conflict_with_progress(
287    session: &Session,
288    remote: Option<&str>,
289    now: i64,
290    mut progress: impl FnMut(PushProgress),
291) -> Result<()> {
292    let repo = &session.repo;
293    let ns = session.namespace();
294
295    let remote_name = git_utils::resolve_meta_remote(repo, remote)?;
296    let local_ref = session.local_ref();
297    let remote_refspec = format!("refs/{ns}/main");
298    let remote_tracking_ref = format!("refs/{ns}/remotes/main");
299
300    // Fetch latest remote data
301    let fetch_refspec = format!("{remote_refspec}:{remote_tracking_ref}");
302    progress(PushProgress::FetchingRemote {
303        remote_name: remote_name.clone(),
304        remote_ref: remote_refspec,
305    });
306    git_utils::run_git(repo, &["fetch", &remote_name, &fetch_refspec])?;
307
308    // Hydrate tip tree blobs so gix can read them
309    let short_ref = format!("{ns}/remotes/main");
310    progress(PushProgress::HydratingRemoteTip);
311    git_utils::hydrate_tip_blobs(repo, &remote_name, &short_ref)?;
312
313    // Materialize the remote data (merge into local DB)
314    progress(PushProgress::MaterializingRemote);
315    let _ = crate::materialize::run(session, None, now)?;
316
317    // Re-serialize with merged data
318    progress(PushProgress::SerializingMerged);
319    let _ = crate::serialize::run(session, now, false)?;
320
321    // Rewrite local ref as a single commit on top of the remote tip.
322    // This avoids merge commits in the pushed history — the spec
323    // requires that push always produces a single fast-forward commit.
324    progress(PushProgress::RebasingMerged);
325    rebase_local_on_remote(repo, &local_ref, &remote_tracking_ref)?;
326
327    Ok(())
328}
329
330/// Rewrite the local ref as a single non-merge commit whose parent is the
331/// remote tip and whose tree is the current local ref's tree.
332///
333/// This ensures the pushed history is always a clean fast-forward with
334/// no merge commits.
335fn rebase_local_on_remote(repo: &gix::Repository, local_ref: &str, remote_ref: &str) -> Result<()> {
336    let local_ref_obj = repo
337        .find_reference(local_ref)
338        .map_err(|e| Error::Other(format!("{e}")))?;
339    let local_oid = local_ref_obj
340        .into_fully_peeled_id()
341        .map_err(|e| Error::Other(format!("{e}")))?
342        .detach();
343    let local_commit_obj = local_oid
344        .attach(repo)
345        .object()
346        .map_err(|e| Error::Other(format!("{e}")))?
347        .into_commit();
348    let local_decoded = local_commit_obj
349        .decode()
350        .map_err(|e| Error::Other(format!("{e}")))?;
351
352    let remote_ref_obj = repo
353        .find_reference(remote_ref)
354        .map_err(|e| Error::Other(format!("{e}")))?;
355    let remote_oid = remote_ref_obj
356        .into_fully_peeled_id()
357        .map_err(|e| Error::Other(format!("{e}")))?
358        .detach();
359
360    // If the local commit is already a single-parent child of remote, nothing to do
361    let parent_ids: Vec<gix::ObjectId> = local_decoded.parents().collect();
362    if parent_ids.len() == 1 && parent_ids[0] == remote_oid {
363        return Ok(());
364    }
365
366    let tree_id = local_decoded.tree();
367    let message = local_decoded.message.to_owned();
368    let author_ref = local_decoded
369        .author()
370        .map_err(|e| Error::Other(format!("{e}")))?;
371
372    let commit = gix::objs::Commit {
373        message,
374        tree: tree_id,
375        author: gix::actor::Signature {
376            name: author_ref.name.into(),
377            email: author_ref.email.into(),
378            time: author_ref
379                .time()
380                .map_err(|e| Error::Other(format!("{e}")))?,
381        },
382        committer: gix::actor::Signature {
383            name: author_ref.name.into(),
384            email: author_ref.email.into(),
385            time: author_ref
386                .time()
387                .map_err(|e| Error::Other(format!("{e}")))?,
388        },
389        encoding: None,
390        parents: vec![remote_oid].into(),
391        extra_headers: Default::default(),
392    };
393
394    let new_oid = repo
395        .write_object(&commit)
396        .map_err(|e| Error::Other(format!("{e}")))?
397        .detach();
398    repo.reference(
399        local_ref,
400        new_oid,
401        PreviousValue::Any,
402        "git-meta: rebase for push",
403    )
404    .map_err(|e| Error::Other(format!("{e}")))?;
405
406    Ok(())
407}