Skip to main content

libgrite_git/
sync.rs

1//! Push/pull sync operations for WAL and snapshots
2//!
3//! Handles synchronization with remote repositories including
4//! conflict resolution for non-fast-forward pushes.
5
6use std::path::Path;
7use std::cell::RefCell;
8use std::rc::Rc;
9use git2::{Oid, Repository, FetchOptions, PushOptions, RemoteCallbacks};
10use libgrite_core::types::event::Event;
11use libgrite_core::types::ids::ActorId;
12
13use crate::wal::WalManager;
14use crate::GitError;
15
16/// Refspec for grit refs
17pub const GRIT_REFSPEC: &str = "refs/grite/*:refs/grite/*";
18
19/// Result of a pull operation
20#[derive(Debug)]
21pub struct PullResult {
22    /// Whether the pull succeeded
23    pub success: bool,
24    /// New WAL head after pull (if changed)
25    pub new_wal_head: Option<Oid>,
26    /// Number of new events pulled
27    pub events_pulled: usize,
28    /// Message describing what happened
29    pub message: String,
30}
31
32/// Result of a push operation
33#[derive(Debug)]
34pub struct PushResult {
35    /// Whether the push succeeded
36    pub success: bool,
37    /// Whether a rebase was needed
38    pub rebased: bool,
39    /// Number of events rebased (if any)
40    pub events_rebased: usize,
41    /// Message describing what happened
42    pub message: String,
43}
44
45/// Manager for sync operations
46pub struct SyncManager {
47    repo: Repository,
48    git_dir: std::path::PathBuf,
49}
50
51impl SyncManager {
52    /// Open a sync manager for the repository
53    pub fn open(git_dir: &Path) -> Result<Self, GitError> {
54        let repo_path = git_dir.parent().ok_or(GitError::NotARepo)?;
55        let repo = Repository::open(repo_path)?;
56        Ok(Self {
57            repo,
58            git_dir: git_dir.to_path_buf(),
59        })
60    }
61
62    /// Pull grit refs from a remote
63    pub fn pull(&self, remote_name: &str) -> Result<PullResult, GitError> {
64        let wal = WalManager::open(&self.git_dir)?;
65        let old_head = wal.head()?;
66
67        // Fetch refs/grite/* from remote
68        let mut remote = self.repo.find_remote(remote_name)?;
69        let refspecs = [GRIT_REFSPEC];
70
71        let mut callbacks = RemoteCallbacks::new();
72        callbacks.transfer_progress(|_stats| true);
73
74        let mut fetch_options = FetchOptions::new();
75        fetch_options.remote_callbacks(callbacks);
76
77        remote.fetch(&refspecs, Some(&mut fetch_options), None)?;
78
79        // Check if WAL head changed
80        let new_head = wal.head()?;
81        let events_pulled = if new_head != old_head {
82            if let Some(_new_oid) = new_head {
83                if let Some(old_oid) = old_head {
84                    wal.read_since(old_oid)?.len()
85                } else {
86                    wal.read_all()?.len()
87                }
88            } else {
89                0
90            }
91        } else {
92            0
93        };
94
95        Ok(PullResult {
96            success: true,
97            new_wal_head: new_head,
98            events_pulled,
99            message: if events_pulled > 0 {
100                format!("Pulled {} new events", events_pulled)
101            } else {
102                "Already up to date".to_string()
103            },
104        })
105    }
106
107    /// Push grit refs to a remote
108    pub fn push(&self, remote_name: &str) -> Result<PushResult, GitError> {
109        let mut remote = self.repo.find_remote(remote_name)?;
110        let refspecs = [GRIT_REFSPEC];
111
112        let push_error: Rc<RefCell<Option<String>>> = Rc::new(RefCell::new(None));
113        let push_error_clone = Rc::clone(&push_error);
114
115        let mut callbacks = RemoteCallbacks::new();
116        callbacks.push_update_reference(move |refname, status| {
117            if let Some(msg) = status {
118                *push_error_clone.borrow_mut() = Some(format!("{}: {}", refname, msg));
119            }
120            Ok(())
121        });
122
123        let mut push_options = PushOptions::new();
124        push_options.remote_callbacks(callbacks);
125
126        remote.push(&refspecs, Some(&mut push_options))?;
127
128        // Now check if there was an error
129        let error = push_error.borrow().clone();
130        if let Some(error_msg) = error {
131            // Push was rejected - likely non-fast-forward
132            return Ok(PushResult {
133                success: false,
134                rebased: false,
135                events_rebased: 0,
136                message: format!("Push rejected: {}", error_msg),
137            });
138        }
139
140        Ok(PushResult {
141            success: true,
142            rebased: false,
143            events_rebased: 0,
144            message: "Push successful".to_string(),
145        })
146    }
147
148    /// Push with automatic rebase on conflict
149    ///
150    /// If push is rejected due to non-fast-forward, this will:
151    /// 1. Record local head
152    /// 2. Pull remote changes (which updates local ref)
153    /// 3. Find events that were local-only
154    /// 4. Re-append those events on top of remote head
155    /// 5. Push again
156    pub fn push_with_rebase(
157        &self,
158        remote_name: &str,
159        actor_id: &ActorId,
160    ) -> Result<PushResult, GitError> {
161        let wal = WalManager::open(&self.git_dir)?;
162
163        // Record local head before attempting push
164        let local_head = wal.head()?;
165
166        // First try a normal push
167        let result = self.push(remote_name)?;
168        if result.success {
169            return Ok(result);
170        }
171
172        // Push failed - need to rebase
173        // 1. Read local events BEFORE pull overwrites the ref
174        let local_events = if let Some(head_oid) = local_head {
175            wal.read_from_oid(head_oid)?
176        } else {
177            vec![]
178        };
179
180        // 2. Pull to get remote state (this updates local ref to remote's head)
181        self.pull(remote_name)?;
182
183        // 3. Get remote events to find which local events are unique
184        let remote_head = wal.head()?;
185        let remote_events = if let Some(head_oid) = remote_head {
186            wal.read_from_oid(head_oid)?
187        } else {
188            vec![]
189        };
190
191        // 4. Find events that exist in local but not in remote (by event_id)
192        let remote_event_ids: std::collections::HashSet<_> =
193            remote_events.iter().map(|e| e.event_id).collect();
194        let unique_local_events: Vec<Event> = local_events
195            .into_iter()
196            .filter(|e| !remote_event_ids.contains(&e.event_id))
197            .collect();
198
199        // 5. Re-append our unique events on top
200        let events_rebased = unique_local_events.len();
201        if !unique_local_events.is_empty() {
202            wal.append(actor_id, &unique_local_events)?;
203        }
204
205        // 6. Try push again
206        let retry_result = self.push(remote_name)?;
207
208        Ok(PushResult {
209            success: retry_result.success,
210            rebased: true,
211            events_rebased,
212            message: if retry_result.success {
213                format!("Push successful after rebase ({} events rebased)", events_rebased)
214            } else {
215                retry_result.message
216            },
217        })
218    }
219
220    /// Sync (pull then push)
221    pub fn sync(&self, remote_name: &str) -> Result<(PullResult, PushResult), GitError> {
222        let pull_result = self.pull(remote_name)?;
223        let push_result = self.push(remote_name)?;
224        Ok((pull_result, push_result))
225    }
226
227    /// Sync with automatic rebase (pull then push with conflict resolution)
228    pub fn sync_with_rebase(
229        &self,
230        remote_name: &str,
231        actor_id: &ActorId,
232    ) -> Result<(PullResult, PushResult), GitError> {
233        let pull_result = self.pull(remote_name)?;
234        let push_result = self.push_with_rebase(remote_name, actor_id)?;
235        Ok((pull_result, push_result))
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    // Sync tests require two repos and are more complex to set up
242    // These would typically be integration tests
243
244    #[test]
245    fn test_sync_manager_opens() {
246        use tempfile::TempDir;
247        use std::process::Command;
248
249        let temp = TempDir::new().unwrap();
250        Command::new("git")
251            .args(["init"])
252            .current_dir(temp.path())
253            .output()
254            .unwrap();
255
256        let git_dir = temp.path().join(".git");
257        let mgr = super::SyncManager::open(&git_dir);
258        assert!(mgr.is_ok());
259    }
260}