Skip to main content

cli/client/
local_sync.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Local repository synchronization.
3//!
4//! Direct access to local repositories without network protocol overhead.
5
6use std::{
7    collections::{HashSet, VecDeque},
8    path::Path,
9};
10
11use anyhow::{Result, anyhow};
12use objects::{
13    object::{ActionId, ContentHash, StateAttachment, StateId},
14    store::ObjectStore,
15};
16use refs::RefExpectation;
17use repo::{CollaborationStore, CollaborationWriteDisposition, Repository};
18use wire::{
19    GitLaneTransferIntent, ObjectId, ObjectType, PlannedObject, RepositoryTransferPlan,
20    StateClosureOptions,
21};
22
23/// Synchronize objects from a local source repository to a target repository.
24pub struct LocalSync {
25    source: Repository,
26}
27
28impl LocalSync {
29    /// Open a local repository for synchronization.
30    pub fn open(path: &Path) -> Result<Self> {
31        let source = Repository::open(path)?;
32        Ok(Self { source })
33    }
34
35    /// Get the source repository.
36    pub fn source(&self) -> &Repository {
37        &self.source
38    }
39
40    /// List all threads in the source repository.
41    pub fn list_threads(&self) -> Result<Vec<(String, StateId)>> {
42        let mut threads = Vec::new();
43        for thread in self.source.refs().list_threads()? {
44            if let Some(state_id) = self.source.refs().get_thread(&thread)? {
45                threads.push((thread.to_string(), state_id));
46            }
47        }
48        Ok(threads)
49    }
50
51    /// List all markers in the source repository.
52    pub fn list_markers(&self) -> Result<Vec<(String, StateId)>> {
53        let mut markers = Vec::new();
54        for marker in self.source.refs().list_markers()? {
55            if let Some(state_id) = self.source.refs().get_marker(&marker)? {
56                markers.push((marker.to_string(), state_id));
57            }
58        }
59        Ok(markers)
60    }
61
62    /// Fetch a state and all its dependencies from source to target.
63    pub fn fetch_state(&self, target: &Repository, state_id: &StateId) -> Result<usize> {
64        let transfer_plan = self.plan_state_transfer(*state_id, None)?;
65        self.copy_transfer_plan(target, &transfer_plan)
66    }
67
68    /// Fetch a state with limited depth (shallow clone).
69    ///
70    /// Depth 1 means the target state and its immediate parents.
71    /// A depth of 0 should be treated by callers as "full history".
72    pub fn fetch_state_with_depth(
73        &self,
74        target: &Repository,
75        state_id: &StateId,
76        depth: u32,
77    ) -> Result<usize> {
78        let state_already_present = target.store().get_state(state_id)?.is_some();
79        let transfer_plan = self.plan_state_transfer(*state_id, Some(depth))?;
80        let copied = self.copy_transfer_plan(target, &transfer_plan)?;
81        if !state_already_present {
82            self.mark_shallow_boundaries(target, *state_id, depth)?;
83        }
84        Ok(copied)
85    }
86
87    fn plan_state_transfer(
88        &self,
89        state_id: StateId,
90        depth: Option<u32>,
91    ) -> Result<RepositoryTransferPlan> {
92        // Local sync still executes through the existing dependency-preserving
93        // recursive copy path. The shared plan gives local and hosted Heddle
94        // object sync the same partition/stats contract without introducing a
95        // second local storage executor.
96        Ok(RepositoryTransferPlan::from_state_closure_plan(
97            self.source.store(),
98            state_id,
99            StateClosureOptions {
100                depth,
101                exclude_states: Vec::new(),
102            },
103            GitLaneTransferIntent::HeddleObjectsOnly,
104        )?)
105    }
106
107    fn copy_transfer_plan(
108        &self,
109        target: &Repository,
110        transfer_plan: &RepositoryTransferPlan,
111    ) -> Result<usize> {
112        let mut copied = 0;
113        for object in &transfer_plan.partitions.packable_objects {
114            if object.obj_type == ObjectType::StateAttachment {
115                continue;
116            }
117            if self.copy_planned_object(target, object)? {
118                copied += 1;
119            }
120        }
121        copied += self.copy_planned_attachments(
122            target,
123            transfer_plan
124                .partitions
125                .packable_objects
126                .iter()
127                .filter(|object| object.obj_type == ObjectType::StateAttachment),
128        )?;
129        for object in &transfer_plan.partitions.sidecar_objects {
130            self.copy_planned_sidecar(target, object)?;
131        }
132        copied += self.copy_collaboration(target)?;
133        Ok(copied)
134    }
135
136    fn copy_collaboration(&self, target: &Repository) -> Result<usize> {
137        let source = CollaborationStore::open(self.source.heddle_dir())?;
138        let target = CollaborationStore::open(target.heddle_dir())?;
139        let mut copied = 0;
140        for id in source.operation_ids()? {
141            let operation = source
142                .read_operation(&id)?
143                .ok_or_else(|| anyhow!("Collaboration operation {id} not found in source"))?;
144            let bytes = operation.operation.encode()?;
145            if target.write_operation_bytes(&bytes)?.disposition
146                == CollaborationWriteDisposition::Created
147            {
148                copied += 1;
149            }
150        }
151        Ok(copied)
152    }
153
154    pub fn fetch_markers(&self, target: &Repository) -> Result<usize> {
155        let mut copied = 0;
156        for (name, state) in self.list_markers()? {
157            copied += self.fetch_state(target, &state)?;
158            let name = objects::object::MarkerName::new(name);
159            let current = target.refs().get_marker(&name)?;
160            if current != Some(state) {
161                let expected = current.map_or(RefExpectation::Missing, RefExpectation::Value);
162                target.refs().set_marker_cas(&name, expected, &state)?;
163            }
164        }
165        Ok(copied)
166    }
167
168    fn copy_planned_attachments<'a>(
169        &self,
170        target: &Repository,
171        objects: impl Iterator<Item = &'a PlannedObject>,
172    ) -> Result<usize> {
173        let mut pending = Vec::new();
174        for object in objects {
175            let ObjectId::StateAttachment { state, id, kind: _ } = &object.id else {
176                return Err(anyhow!(
177                    "transfer plan object {:?} has incompatible type {:?}",
178                    object.id,
179                    object.obj_type
180                ));
181            };
182            let attachment = self
183                .source
184                .store()
185                .get_state_attachment(state, id)?
186                .ok_or_else(|| anyhow!("State attachment {} not found in source", id))?;
187            pending.push(attachment);
188        }
189
190        let mut copied = 0;
191        while !pending.is_empty() {
192            let mut ready = None;
193            for (index, attachment) in pending.iter().enumerate() {
194                if self.attachment_is_ready(target, attachment)? {
195                    ready = Some(index);
196                    break;
197                }
198            }
199            let Some(index) = ready else {
200                let state_id = pending[0].state_id;
201                return Err(anyhow!(
202                    "state attachment history for {} has an unresolved predecessor",
203                    state_id
204                ));
205            };
206            let attachment = pending.remove(index);
207            if target
208                .get_state_attachment(&attachment.state_id, &attachment.id())?
209                .is_none()
210            {
211                target.put_state_attachment(&attachment)?;
212                copied += 1;
213            }
214        }
215        Ok(copied)
216    }
217
218    fn attachment_is_ready(
219        &self,
220        target: &Repository,
221        attachment: &StateAttachment,
222    ) -> Result<bool> {
223        if target
224            .get_state_attachment(&attachment.state_id, &attachment.id())?
225            .is_some()
226        {
227            return Ok(true);
228        }
229        match attachment.supersedes {
230            Some(prior) => Ok(target
231                .get_state_attachment(&attachment.state_id, &prior)?
232                .is_some()),
233            None => Ok(true),
234        }
235    }
236
237    fn copy_planned_object(&self, target: &Repository, object: &PlannedObject) -> Result<bool> {
238        match (&object.id, object.obj_type) {
239            (ObjectId::Hash(hash), ObjectType::Blob) => self.copy_blob(target, hash),
240            (ObjectId::Hash(hash), ObjectType::Tree) => self.copy_tree(target, hash),
241            (ObjectId::Hash(hash), ObjectType::Action) => self.copy_action(target, hash),
242            (ObjectId::StateId(state_id), ObjectType::State) => self.copy_state(target, state_id),
243            (_, ObjectType::Redaction | ObjectType::StateVisibility) => Ok(false),
244            (id, obj_type) => Err(anyhow!(
245                "transfer plan object {id:?} has incompatible type {obj_type:?}"
246            )),
247        }
248    }
249
250    fn copy_tree(&self, target: &Repository, tree_hash: &ContentHash) -> Result<bool> {
251        if target.store().has_tree(tree_hash)? {
252            return Ok(false);
253        }
254        let tree = self
255            .source
256            .store()
257            .get_tree(tree_hash)?
258            .ok_or_else(|| anyhow!("Tree {} not found in source", tree_hash))?;
259        target.store().put_tree(&tree)?;
260        Ok(true)
261    }
262
263    fn copy_action(&self, target: &Repository, hash: &ContentHash) -> Result<bool> {
264        let action_id = ActionId::from_hash(*hash);
265        if target.store().get_action(&action_id)?.is_some() {
266            return Ok(false);
267        }
268        let mut action = self
269            .source
270            .store()
271            .get_action(&action_id)?
272            .ok_or_else(|| anyhow!("Action {} not found in source", hash))?;
273        target.store().put_action(&mut action)?;
274        Ok(true)
275    }
276
277    fn copy_state(&self, target: &Repository, state_id: &StateId) -> Result<bool> {
278        let state_already_present = target.store().get_state(state_id)?.is_some();
279        let state = self
280            .source
281            .store()
282            .get_state(state_id)?
283            .ok_or_else(|| anyhow!("State {} not found in source", state_id))?;
284
285        if !state_already_present {
286            target.store().put_state(&state)?;
287        }
288        Ok(!state_already_present)
289    }
290
291    fn copy_planned_sidecar(&self, target: &Repository, object: &PlannedObject) -> Result<()> {
292        match (&object.id, object.obj_type) {
293            (ObjectId::Hash(hash), ObjectType::Redaction) => {
294                self.propagate_redactions_for_blob(target, hash)
295            }
296            (ObjectId::StateId(state_id), ObjectType::StateVisibility) => {
297                self.propagate_state_visibility_for_state(target, state_id)
298            }
299            (_, ObjectType::Blob | ObjectType::Tree | ObjectType::State | ObjectType::Action) => {
300                Ok(())
301            }
302            (id, obj_type) => Err(anyhow!(
303                "transfer plan sidecar {id:?} has incompatible type {obj_type:?}"
304            )),
305        }
306    }
307
308    fn mark_shallow_boundaries(
309        &self,
310        target: &Repository,
311        state_id: StateId,
312        max_depth: u32,
313    ) -> Result<()> {
314        let mut seen: HashSet<StateId> = HashSet::new();
315        let mut queue = VecDeque::from([(state_id, 0u32)]);
316        while let Some((id, depth)) = queue.pop_front() {
317            if !seen.insert(id) {
318                continue;
319            }
320            let state = self
321                .source
322                .store()
323                .get_state(&id)?
324                .ok_or_else(|| anyhow!("State {} not found in source", id))?;
325            if depth == max_depth {
326                if !state.parents.is_empty() {
327                    target.set_shallow(&id, &state.parents)?;
328                }
329                continue;
330            }
331            for parent in &state.parents {
332                queue.push_back((*parent, depth + 1));
333            }
334        }
335        Ok(())
336    }
337
338    /// If the source repository has any redactions for `blob`, ferry
339    /// the sidecar bytes through `Repository::accept_wire_redactions`
340    /// on the target so signatures are verified and any `purged_at`
341    /// records trigger a local purge on the target.
342    ///
343    /// `LocalSync` is local→local, so we use the same wire-side
344    /// contract as the network path — same signature requirement,
345    /// same idempotency. Operators who redact unsigned locally and
346    /// expect that to propagate via a local fetch will see a clear
347    /// rejection rather than a silent skip.
348    fn propagate_redactions_for_blob(&self, target: &Repository, blob: &ContentHash) -> Result<()> {
349        let Some(bytes) = self.source.store().get_redactions_bytes_for_blob(blob)? else {
350            return Ok(());
351        };
352        target.accept_wire_redactions(*blob, &bytes)?;
353        Ok(())
354    }
355
356    /// If the source repository has state-visibility records for `state`,
357    /// ferry the sidecar bytes through the same repository boundary used by
358    /// the network path.
359    fn propagate_state_visibility_for_state(
360        &self,
361        target: &Repository,
362        state: &StateId,
363    ) -> Result<()> {
364        let Some(bytes) = self.source.get_state_visibility_bytes_for_state(state)? else {
365            return Ok(());
366        };
367        target.accept_wire_state_visibility(*state, &bytes)?;
368        Ok(())
369    }
370
371    /// Copy a specific blob from source to target.
372    pub fn copy_blob(&self, target: &Repository, hash: &ContentHash) -> Result<bool> {
373        if target.store().has_blob(hash)? {
374            return Ok(false);
375        }
376
377        let blob = self.source.require_blob(hash)?;
378
379        target.store().put_blob(&blob)?;
380        Ok(true)
381    }
382}
383
384#[cfg(test)]
385mod tests {
386    use objects::object::{
387        Attribution, Blob, Principal, StateAttachment, StateAttachmentBody, Tree, TreeEntry,
388    };
389    use repo::StateAttachmentKind;
390    use tempfile::TempDir;
391
392    use super::*;
393
394    fn attribution() -> Attribution {
395        Attribution::human(Principal::new("Test User", "test@example.com"))
396    }
397
398    fn capture(repo: &Repository, file_content: &str, message: &str) -> StateId {
399        std::fs::write(repo.root().join("file.txt"), file_content).unwrap();
400        repo.snapshot_with_attribution(Some(message.to_string()), None, attribution())
401            .unwrap()
402            .state_id
403    }
404
405    #[test]
406    fn shallow_refetch_does_not_graft_already_present_history() {
407        let source_dir = TempDir::new().unwrap();
408        let target_dir = TempDir::new().unwrap();
409        let source = Repository::init_default(source_dir.path()).unwrap();
410        let target = Repository::init_default(target_dir.path()).unwrap();
411
412        let _first = capture(&source, "one\n", "one");
413        let second = capture(&source, "two\n", "two");
414        let third = capture(&source, "three\n", "three");
415
416        let sync = LocalSync::open(source_dir.path()).unwrap();
417        sync.fetch_state(&target, &third).unwrap();
418
419        assert!(
420            target.store().get_state(&second).unwrap().is_some(),
421            "full fetch should copy the parent state before the shallow re-fetch"
422        );
423        assert!(
424            !target.is_shallow(&second),
425            "parent starts as normal visible history"
426        );
427
428        sync.fetch_state_with_depth(&target, &third, 1).unwrap();
429
430        assert!(
431            !target.is_shallow(&second),
432            "incremental shallow re-fetch of an already-present tip must not graft its parent"
433        );
434    }
435
436    #[test]
437    fn fetch_copies_attachment_history_and_context_objects() {
438        let source_dir = TempDir::new().unwrap();
439        let target_dir = TempDir::new().unwrap();
440        let source = Repository::init_default(source_dir.path()).unwrap();
441        let target = Repository::init_default(target_dir.path()).unwrap();
442        let state_id = capture(&source, "one\n", "one");
443
444        let context_blob = source
445            .store()
446            .put_blob(&Blob::from("context"))
447            .expect("put context blob");
448        let context_root = source
449            .store()
450            .put_tree(&Tree::from_entries(vec![
451                TreeEntry::file("context.msgpack", context_blob, false).unwrap(),
452            ]))
453            .expect("put context tree");
454        let first = StateAttachment {
455            state_id,
456            body: StateAttachmentBody::Context(context_root),
457            attribution: attribution(),
458            created_at: chrono::Utc::now(),
459            supersedes: None,
460        };
461        let first_id = source.put_state_attachment(&first).expect("put first");
462        let second = StateAttachment {
463            state_id,
464            body: StateAttachmentBody::Context(context_root),
465            attribution: attribution(),
466            created_at: first.created_at + chrono::Duration::seconds(1),
467            supersedes: Some(first_id),
468        };
469        source.put_state_attachment(&second).expect("put second");
470
471        LocalSync::open(source_dir.path())
472            .unwrap()
473            .fetch_state(&target, &state_id)
474            .expect("fetch state");
475
476        assert!(target.store().get_tree(&context_root).unwrap().is_some());
477        assert!(target.store().get_blob(&context_blob).unwrap().is_some());
478        assert_eq!(
479            target
480                .latest_state_attachment(&state_id, StateAttachmentKind::Context)
481                .unwrap(),
482            Some(second)
483        );
484    }
485}