1234567891011module sync1213import os14import src.events15import src.history16import src.remote17import src.storage181920const repo_state_key = 'remote_repo'21const offered_head_key = 'remote_head'2223pub const no_saves = 'this repo has no saves yet — nothing to offer'2425pub struct OfferReport {26pub mut:27 repo string28 saves int29 objects int30 events int31 head string32}33343536pub fn repo_name(store &storage.Store, mut backend remote.Backend) !string {37 if remembered := store.state(repo_state_key) {38 return remembered39 }40 name := os.base(store.root)41 existing := backend.get(head_key(name))!42 if existing.found {43 theirs := existing.body.bytestr().trim_space()44 if history.by_id(store, theirs) == none {45 return error('the remote already has "${name}" with a history this repo does not share — rename the folder, or use another remote')46 }47 }48 store.set_state(repo_state_key, name)!49 return name50}51525354pub fn offer(store &storage.Store, mut backend remote.Backend) !OfferReport {55 name := repo_name(store, mut backend)!56 local := history.head(store) or { return error(no_saves) }57 mut report := OfferReport{58 repo: name59 head: local.id60 }61 remote_head := backend.get(head_key(name))!62 if remote_head.found {63 theirs := remote_head.body.bytestr().trim_space()64 newer := saves_after(store, local.id, theirs) or {65 return error('remote has new saves — run koh steal')66 }67 for save in newer {68 upload_save(store, mut backend, name, save, mut report)!69 }70 } else {71 oldest_first := history.line(store)!72 for index := oldest_first.len - 1; index >= 0; index-- {73 upload_save(store, mut backend, name, oldest_first[index], mut report)!74 }75 }76 upload_events(store, mut backend, name, mut report)!77 if !backend.put_if_match(head_key(name), local.id.bytes(), remote_head.tag)! {78 return error('remote has new saves — run koh steal')79 }80 store.set_state(offered_head_key, local.id)!81 return report82}8384858687fn saves_after(store &storage.Store, ours string, theirs string) ![]history.Save {88 mut newer := []history.Save{}89 mut cursor := ours90 for cursor != '' && cursor != theirs {91 save := history.by_id(store, cursor) or { return error('save ${cursor} is missing') }92 newer << save93 cursor = save.parent94 }95 if cursor != theirs {96 return error('${theirs} is not on this line')97 }98 newer.reverse_in_place()99 return newer100}101102fn upload_save(store &storage.Store, mut backend remote.Backend, repo string, save history.Save, mut report OfferReport) ! {103 files := history.files_of(store, save.id)!104 for file in files {105 if backend.put_if_absent(object_key(repo, file.hash), store.object_blob(file.hash)!)! {106 report.objects++107 }108 }109 record := save_record(store, save)!110 if backend.put_if_absent(save_key(repo, save.id), encode_save(record).bytes())! {111 report.saves++112 }113}114115116117fn upload_events(store &storage.Store, mut backend remote.Backend, repo string, mut report OfferReport) ! {118 already := backend.list(events_prefix(repo))!119 mut already_sent := map[string]bool{}120 for key in already {121 already_sent[os.base(key)] = true122 }123 for event in events.all(store)! {124 if event.id in already_sent {125 continue126 }127 if backend.put_if_absent(event_key(repo, event.id), encode_event(event).bytes())! {128 report.events++129 }130 }131}132133134135136pub struct Fetched {137pub mut:138 repo_objects map[string][]u8139 saves []SaveRecord 140 events_out []events.Event141 head string142 base string 143 remote_save_ids map[string]bool144}145146pub fn fetch_back_to_known(store &storage.Store, mut backend remote.Backend, repo string) !Fetched {147 remote_head := backend.get(head_key(repo))!148 if !remote_head.found {149 return error('the remote has no "${repo}" yet — run koh offer here first')150 }151 mut fetched := Fetched{152 head: remote_head.body.bytestr().trim_space()153 remote_save_ids: map[string]bool{}154 }155 mut cursor := fetched.head156 for cursor != '' && history.by_id(store, cursor) == none {157 record_object := backend.get(save_key(repo, cursor))!158 if !record_object.found {159 return error('the remote is missing save ${cursor} — run koh offer there')160 }161 record := decode_save(record_object.body)!162 for file in record.files {163 if !store.has_object(file.hash)! && file.hash !in fetched.repo_objects {164 blob := backend.get(object_key(repo, file.hash))!165 if !blob.found {166 return error('the remote is missing object ${file.hash} that save ${record.id} needs')167 }168 fetched.repo_objects[file.hash] = blob.body169 }170 }171 fetched.remote_save_ids[record.id] = true172 fetched.saves << record173 cursor = record.parent174 }175 fetched.saves.reverse_in_place()176 fetched.base = cursor177 fetched.events_out = fetch_events(store, mut backend, repo)!178 return fetched179}180181fn fetch_events(store &storage.Store, mut backend remote.Backend, repo string) ![]events.Event {182 have := events.all(store)!183 mut known := map[string]bool{}184 for event in have {185 known[event.id] = true186 }187 mut incoming := []events.Event{}188 for key in backend.list(events_prefix(repo))! {189 id := os.base(key)190 if id in known {191 continue192 }193 body := backend.get(event_key(repo, id))!194 if !body.found {195 continue196 }197 incoming << decode_event(body.body)!198 }199 return incoming200}201202203204pub fn write_fetched(store &storage.Store, fetched Fetched) ! {205 for hash, blob in fetched.repo_objects {206 store.put_object_blob(hash, blob)!207 }208 for record in fetched.saves {209 history.import_save(store, to_save(record), to_files(record))!210 }211 for event in fetched.events_out {212 if !events.receive(store, event)! {213 continue214 }215 if event.kind == events.kind_strip {216 217 218 apply_strip(store, event)!219 }220 }221}222223224225pub fn can_fast_forward(store &storage.Store, remote_head string, remote_ids map[string]bool) bool {226 mut cursor := remote_head227 for cursor != '' {228 if history.by_id(store, cursor) == none {229 return false230 }231 save := history.by_id(store, cursor) or { return false }232 if save.parent == '' {233 234 return true235 }236 cursor = save.parent237 }238 return false239}240241242pub struct PushReport {243pub mut:244 records int245 objects int246 event_sent bool247}248249250251252253254255256257pub fn push_strip(store &storage.Store, mut backend remote.Backend, path string, save_ids []string, object_hashes []string, drop_objects bool) !PushReport {258 name := repo_name(store, mut backend)!259 mut report := PushReport{}260 mut upload := OfferReport{261 repo: name262 }263 upload_events(store, mut backend, name, mut upload)!264 report.event_sent = upload.events > 0265266 for save_id in save_ids {267 remote_object := backend.get(save_key(name, save_id))!268 if !remote_object.found {269 continue270 }271 record := decode_save(remote_object.body)!272 mut kept := []FileRecord{}273 for file in record.files {274 if file.path == path || file.path.starts_with('${path.trim_right('/')}/') {275 continue276 }277 kept << file278 }279 if kept.len == record.files.len {280 continue281 }282 if !backend.put_if_match(save_key(name, save_id),283 encode_save(rewrite(record, kept)).bytes(), remote_object.tag)! {284 return error('the remote copy of save ${save_id} moved while stripping — run koh steal, then strip again')285 }286 report.records++287 }288 if drop_objects {289 for hash in object_hashes {290 291 292 if !store.has_object(hash)! {293 backend.delete(object_key(name, hash))!294 report.objects++295 }296 }297 }298 return report299}300301302fn rewrite(record SaveRecord, files []FileRecord) SaveRecord {303 return SaveRecord{304 id: record.id305 parent: record.parent306 message: record.message307 created_at: record.created_at308 machine: record.machine309 is_checkpoint: record.is_checkpoint310 size_bytes: record.size_bytes311 agent: record.agent312 session: record.session313 restores: record.restores314 replayed_from: record.replayed_from315 files: files316 }317}318319320321322pub fn apply_strip(store &storage.Store, event events.Event) !int {323 mut query := "DELETE FROM save_files WHERE (path = ? OR path LIKE ? ESCAPE '\\')"324 if event.save_id != '' {325 query += ' AND save_id = ?'326 }327 mut stmt := store.db.prepare(query)!328 stmt.bind_text(1, event.path)!329 stmt.bind_text(2, like_under(event.path))!330 if event.save_id != '' {331 stmt.bind_text(3, event.save_id)!332 }333 stmt.step()!334 removed := store.db.changes()335 stmt.finalize()336 if removed > 0 {337 store.drop_unreferenced_objects()!338 }339 return removed340}341342343344fn like_under(folder string) string {345 escaped := folder.replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_')346 return '${escaped}/%'347}