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())? {
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) -> Result<bool> {
230    if local_oid.is_none() {
231        return Ok(true);
232    };
233
234    let Some(last_materialized) = session.store.get_last_materialized()? else {
235        return Ok(true);
236    };
237
238    Ok(!session
239        .store
240        .get_modified_since(last_materialized)?
241        .is_empty())
242}
243
244/// After a failed push, fetch remote changes, materialize, re-serialize,
245/// and rebase local ref for clean fast-forward.
246///
247/// Call this between push retries. It fetches the latest remote data,
248/// hydrates tip blobs, materializes changes into the local store,
249/// re-serializes the merged data, and rebases the local ref on top of
250/// the remote tip so the next push is a clean fast-forward.
251///
252/// # Parameters
253///
254/// - `session`: the gmeta session providing the repository, store, and config.
255/// - `remote`: optional remote name. If `None`, the first configured
256///   metadata remote is used.
257/// - `now`: the current timestamp in milliseconds since the Unix epoch,
258///   used for database writes during materialization.
259///
260/// # Errors
261///
262/// Returns an error if fetch, materialization, serialization, or rebase fails.
263pub fn resolve_push_conflict(session: &Session, remote: Option<&str>, now: i64) -> Result<()> {
264    resolve_push_conflict_with_progress(session, remote, now, |_| {})
265}
266
267/// Resolve a non-fast-forward push rejection and report phase progress.
268///
269/// # Parameters
270///
271/// - `session`: the gmeta session providing the repository, store, and config.
272/// - `remote`: optional remote name. If `None`, the first configured
273///   metadata remote is used.
274/// - `now`: the current timestamp in milliseconds since the Unix epoch.
275/// - `progress`: callback invoked before long-running phases.
276///
277/// # Errors
278///
279/// Returns an error if fetch, hydration, materialization, serialization, or
280/// rebase fails.
281pub fn resolve_push_conflict_with_progress(
282    session: &Session,
283    remote: Option<&str>,
284    now: i64,
285    mut progress: impl FnMut(PushProgress),
286) -> Result<()> {
287    let repo = &session.repo;
288    let ns = session.namespace();
289
290    let remote_name = git_utils::resolve_meta_remote(repo, remote)?;
291    let local_ref = session.local_ref();
292    let remote_refspec = format!("refs/{ns}/main");
293    let remote_tracking_ref = format!("refs/{ns}/remotes/main");
294
295    // Fetch latest remote data
296    let fetch_refspec = format!("{remote_refspec}:{remote_tracking_ref}");
297    progress(PushProgress::FetchingRemote {
298        remote_name: remote_name.clone(),
299        remote_ref: remote_refspec,
300    });
301    git_utils::run_git(repo, &["fetch", &remote_name, &fetch_refspec])?;
302
303    // Hydrate tip tree blobs so gix can read them
304    let short_ref = format!("{ns}/remotes/main");
305    progress(PushProgress::HydratingRemoteTip);
306    git_utils::hydrate_tip_blobs(repo, &remote_name, &short_ref)?;
307
308    // Materialize the remote data (merge into local DB)
309    progress(PushProgress::MaterializingRemote);
310    let _ = crate::materialize::run(session, None, now)?;
311
312    // Re-serialize with merged data
313    progress(PushProgress::SerializingMerged);
314    let _ = crate::serialize::run(session, now, false)?;
315
316    // Rewrite local ref as a single commit on top of the remote tip.
317    // This avoids merge commits in the pushed history — the spec
318    // requires that push always produces a single fast-forward commit.
319    progress(PushProgress::RebasingMerged);
320    rebase_local_on_remote(repo, &local_ref, &remote_tracking_ref)?;
321
322    Ok(())
323}
324
325/// Rewrite the local ref as a single non-merge commit whose parent is the
326/// remote tip and whose tree is the current local ref's tree.
327///
328/// This ensures the pushed history is always a clean fast-forward with
329/// no merge commits.
330fn rebase_local_on_remote(repo: &gix::Repository, local_ref: &str, remote_ref: &str) -> Result<()> {
331    let local_ref_obj = repo
332        .find_reference(local_ref)
333        .map_err(|e| Error::Other(format!("{e}")))?;
334    let local_oid = local_ref_obj
335        .into_fully_peeled_id()
336        .map_err(|e| Error::Other(format!("{e}")))?
337        .detach();
338    let local_commit_obj = local_oid
339        .attach(repo)
340        .object()
341        .map_err(|e| Error::Other(format!("{e}")))?
342        .into_commit();
343    let local_decoded = local_commit_obj
344        .decode()
345        .map_err(|e| Error::Other(format!("{e}")))?;
346
347    let remote_ref_obj = repo
348        .find_reference(remote_ref)
349        .map_err(|e| Error::Other(format!("{e}")))?;
350    let remote_oid = remote_ref_obj
351        .into_fully_peeled_id()
352        .map_err(|e| Error::Other(format!("{e}")))?
353        .detach();
354
355    // If the local commit is already a single-parent child of remote, nothing to do
356    let parent_ids: Vec<gix::ObjectId> = local_decoded.parents().collect();
357    if parent_ids.len() == 1 && parent_ids[0] == remote_oid {
358        return Ok(());
359    }
360
361    let tree_id = local_decoded.tree();
362    let message = local_decoded.message.to_owned();
363    let author_ref = local_decoded
364        .author()
365        .map_err(|e| Error::Other(format!("{e}")))?;
366
367    let commit = gix::objs::Commit {
368        message,
369        tree: tree_id,
370        author: gix::actor::Signature {
371            name: author_ref.name.into(),
372            email: author_ref.email.into(),
373            time: author_ref
374                .time()
375                .map_err(|e| Error::Other(format!("{e}")))?,
376        },
377        committer: gix::actor::Signature {
378            name: author_ref.name.into(),
379            email: author_ref.email.into(),
380            time: author_ref
381                .time()
382                .map_err(|e| Error::Other(format!("{e}")))?,
383        },
384        encoding: None,
385        parents: vec![remote_oid].into(),
386        extra_headers: Default::default(),
387    };
388
389    let new_oid = repo
390        .write_object(&commit)
391        .map_err(|e| Error::Other(format!("{e}")))?
392        .detach();
393    repo.reference(
394        local_ref,
395        new_oid,
396        PreviousValue::Any,
397        "git-meta: rebase for push",
398    )
399    .map_err(|e| Error::Other(format!("{e}")))?;
400
401    Ok(())
402}
403
404#[cfg(test)]
405#[allow(clippy::expect_used, clippy::unwrap_used)]
406mod tests {
407    use super::*;
408
409    #[test]
410    fn clean_store_with_local_ref_does_not_need_serialization() {
411        let dir = tempfile::TempDir::new().unwrap();
412        let _repo = gix::init(dir.path()).unwrap();
413        let status = std::process::Command::new("git")
414            .args(["config", "user.email", "test@example.com"])
415            .current_dir(dir.path())
416            .status()
417            .unwrap();
418        assert!(status.success());
419        let status = std::process::Command::new("git")
420            .args(["config", "user.name", "Test User"])
421            .current_dir(dir.path())
422            .status()
423            .unwrap();
424        assert!(status.success());
425
426        let session = Session::open(dir.path()).unwrap();
427        session.store.set_last_materialized(1000).unwrap();
428        let local_oid =
429            gix::ObjectId::from_hex(b"0000000000000000000000000000000000000000").unwrap();
430
431        assert!(!should_serialize_before_push(&session, Some(&local_oid)).unwrap());
432    }
433}