1use 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
23pub struct LocalSync {
25 source: Repository,
26}
27
28impl LocalSync {
29 pub fn open(path: &Path) -> Result<Self> {
31 let source = Repository::open(path)?;
32 Ok(Self { source })
33 }
34
35 pub fn source(&self) -> &Repository {
37 &self.source
38 }
39
40 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 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 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 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 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 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 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 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}