123456789module history1011import strings12import time13import src.machine14import src.snapshot15import src.storage1617pub struct SaveInput {18pub mut:19 message string20 agent string21 session string22 is_checkpoint bool23 restores string24 replayed_from string25 created_at i6426 machine string27}2829pub const nothing_changed = 'nothing has changed since the last save'3031323334pub fn save_worktree(store &storage.Store, root string, ig snapshot.Ignore, input SaveInput) !Save {35 walked_head := head_id(store)36 files := snapshot.walk(store, root, ig)!37 store.begin()!38 save := save_walk(store, root, input, walked_head, files) or {39 store.rollback()40 return err41 }42 store.commit()!43 refresh_log_md(store)44 return save45}4647484950515253fn save_walk(store &storage.Store, root string, input SaveInput, walked_head string, walked []snapshot.FileEntry) !Save {54 if snapshot.restore_pending(store) {55 return error(snapshot.restore_unfinished)56 }57 mut files := walked.clone()58 moved := head_id(store) != walked_head59 if moved {60 files = snapshot.walk(store, root, snapshot.load(root))!61 }62 if input.restores == '' && !input.is_checkpoint {63 if previous := head(store) {64 if snapshot.same_tree(files, files_of(store, previous.id)!) {65 if moved {66 67 68 return error('${nothing_changed} — [${previous.id}] "${previous.message}" was saved a moment ago and already holds this tree')69 }70 return error(nothing_changed)71 }72 }73 }74 return record_save(store, root, files, input)!75}76777879pub fn save_tree(store &storage.Store, root string, files []snapshot.FileEntry, input SaveInput) !Save {80 store.begin()!81 save := record_save(store, root, files, input) or {82 store.rollback()83 return err84 }85 store.commit()!86 refresh_log_md(store)87 return save88}8990919293pub fn record_save(store &storage.Store, root string, files []snapshot.FileEntry, input SaveInput) !Save {94 if root != '' {95 snapshot.store_objects(store, root, files)!96 }97 return write_save(store, files, input)!98}99100fn write_save(store &storage.Store, files []snapshot.FileEntry, input SaveInput) !Save {101 created_at := if input.created_at > 0 { input.created_at } else { time.now().unix() }102 mut save := Save{103 parent: head_id(store)104 message: input.message105 created_at: created_at106 machine: if input.machine != '' { input.machine } else { machine.name() }107 is_checkpoint: input.is_checkpoint108 size_bytes: tree_size(store, files)109 agent: input.agent110 session: input.session111 restores: input.restores112 replayed_from: input.replayed_from113 }114 115 116 for {117 save.id = save_id(save, files)118 if by_id(store, save.id) == none {119 break120 }121 save.created_at++122 }123 insert_save(store, save, files)!124 store.set_state(storage.head_key, save.id)!125 return save126}127128129130131pub fn import_save(store &storage.Store, save Save, files []snapshot.FileEntry) ! {132 insert_save(store, save, files)!133}134135136137138pub fn set_head(store &storage.Store, id string) ! {139 store.set_state(storage.head_key, id)!140}141142fn insert_save(store &storage.Store, save Save, files []snapshot.FileEntry) ! {143 mut stmt := store.db.prepare('INSERT INTO saves (id, parent, message, created_at, machine,144 is_checkpoint, size_bytes, agent, session, restores, replayed_from)145 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')!146 stmt.bind_text(1, save.id)!147 if save.parent == '' {148 stmt.bind_null(2)!149 } else {150 stmt.bind_text(2, save.parent)!151 }152 stmt.bind_text(3, save.message)!153 stmt.bind_int(4, save.created_at)!154 stmt.bind_text(5, save.machine)!155 stmt.bind_int(6, if save.is_checkpoint { 1 } else { 0 })!156 stmt.bind_int(7, save.size_bytes)!157 bind_optional(stmt, 8, save.agent)!158 bind_optional(stmt, 9, save.session)!159 bind_optional(stmt, 10, save.restores)!160 bind_optional(stmt, 11, save.replayed_from)!161 stmt.step()!162 stmt.finalize()163164 mut files_stmt :=165 store.db.prepare('INSERT INTO save_files (save_id, path, hash, executable) VALUES (?, ?, ?, ?)')!166 defer {167 files_stmt.finalize()168 }169 for file in files {170 files_stmt.bind_text(1, save.id)!171 files_stmt.bind_text(2, file.path)!172 files_stmt.bind_text(3, file.hash)!173 files_stmt.bind_int(4, if file.executable { 1 } else { 0 })!174 files_stmt.step()!175 176 files_stmt.reset()177 }178}179180fn bind_optional(stmt &storage.Stmt, index int, value string) ! {181 if value == '' {182 stmt.bind_null(index)!183 } else {184 stmt.bind_text(index, value)!185 }186}187188189190191pub fn save_id(save Save, files []snapshot.FileEntry) string {192 return storage.hash_hex(canonical(save, files).bytes())[..12]193}194195fn canonical(save Save, files []snapshot.FileEntry) string {196 mut sb := strings.Builder{}197 sb.write_string('koh3-save-v1\n')198 put_field(mut sb, 'parent', save.parent)199 put_field(mut sb, 'message', save.message)200 put_field(mut sb, 'created', save.created_at.str())201 put_field(mut sb, 'machine', save.machine)202 put_field(mut sb, 'checkpoint', if save.is_checkpoint { '1' } else { '0' })203 put_field(mut sb, 'agent', save.agent)204 put_field(mut sb, 'session', save.session)205 put_field(mut sb, 'files', files.len.str())206 for file in files {207 mark := if file.executable { 'x' } else { '-' }208 put_field(mut sb, 'file', '${mark}${file.hash} ${file.path}')209 }210 return sb.str()211}212213214215fn put_field(mut sb strings.Builder, name string, value string) {216 sb.write_string('${name}:${value.len}:')217 sb.write_string(value)218 sb.write_string('\n')219}220221fn tree_size(store &storage.Store, files []snapshot.FileEntry) i64 {222 mut total := i64(0)223 for file in files {224 total += store.object_size(file.hash) or { 0 }225 }226 return total227}