//! Creating saves. //! //! A save is a full snapshot: every tracked path with its object hash and //! executable bit, plus the message, time, machine and optional agent and //! session. Its ID is the first 12 hex characters of the Blake3 hash of the //! canonical record, computed once, at creation, and never recomputed — which //! is why `strip` can change a save's files and keep its ID. module history import strings import time import src.machine import src.snapshot import src.storage pub struct SaveInput { pub mut: message string agent string session string is_checkpoint bool restores string replayed_from string created_at i64 machine string } pub const nothing_changed = 'nothing has changed since the last save' // commit_worktree walks the working tree and records it. A plain save refuses // when the tree matches head; a checkpoint always records. pub fn commit_worktree(store &storage.Store, root string, ig snapshot.Ignore, input SaveInput) !Save { files := snapshot.walk(store, root, ig)! if input.restores == '' && !input.is_checkpoint { if previous := head(store) { if snapshot.same_tree(files, files_of(store, previous.id)!) { return error(nothing_changed) } } } return commit_files(store, root, files, input)! } // commit_files records an already-known tree. `root` is '' when the objects are // already in the store — a restore, or a replay during a steal. pub fn commit_files(store &storage.Store, root string, files []snapshot.FileEntry, input SaveInput) !Save { store.begin()! save := record_save(store, root, files, input) or { store.rollback() return err } store.commit()! write_log_md(store)! return save } // record_save writes one save inside the caller's transaction. A steal uses it // to land a whole replay — several saves, fetched objects and fetched events — // in a single step, so a failure leaves no half-combined line behind. pub fn record_save(store &storage.Store, root string, files []snapshot.FileEntry, input SaveInput) !Save { if root != '' { snapshot.store_objects(store, root, files)! } return write_save(store, files, input)! } fn write_save(store &storage.Store, files []snapshot.FileEntry, input SaveInput) !Save { created_at := if input.created_at > 0 { input.created_at } else { time.now().unix() } mut save := Save{ parent: head_id(store) message: input.message created_at: created_at machine: if input.machine != '' { input.machine } else { machine.name() } is_checkpoint: input.is_checkpoint size_bytes: tree_size(store, files) agent: input.agent session: input.session restores: input.restores replayed_from: input.replayed_from } // Two saves made in the same second with the same record would collide. // Bumping the clock keeps one line of history and keeps the ID unique. for { save.id = save_id(save, files) if by_id(store, save.id) == none { break } save.created_at++ } insert_save(store, save, files)! store.set_state(storage.head_key, save.id)! return save } // import_save records a save exactly as another machine made it: same ID, same // parent, same time, same files. `steal` and `migrate` use it; a save made here // goes through write_save instead, which mints the ID. pub fn import_save(store &storage.Store, save Save, files []snapshot.FileEntry) ! { insert_save(store, save, files)! } // set_head moves the line's head without creating a save. Only fetching another // machine's saves does that: nothing is erased and nothing is re-recorded, // because those saves are already on the line. pub fn set_head(store &storage.Store, id string) ! { store.set_state(storage.head_key, id)! } fn insert_save(store &storage.Store, save Save, files []snapshot.FileEntry) ! { mut stmt := store.db.prepare('INSERT INTO saves (id, parent, message, created_at, machine, is_checkpoint, size_bytes, agent, session, restores, replayed_from) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')! stmt.bind_text(1, save.id)! if save.parent == '' { stmt.bind_null(2)! } else { stmt.bind_text(2, save.parent)! } stmt.bind_text(3, save.message)! stmt.bind_int(4, save.created_at)! stmt.bind_text(5, save.machine)! stmt.bind_int(6, if save.is_checkpoint { 1 } else { 0 })! stmt.bind_int(7, save.size_bytes)! bind_optional(stmt, 8, save.agent)! bind_optional(stmt, 9, save.session)! bind_optional(stmt, 10, save.restores)! bind_optional(stmt, 11, save.replayed_from)! stmt.step()! stmt.finalize() mut files_stmt := store.db.prepare('INSERT INTO save_files (save_id, path, hash, executable) VALUES (?, ?, ?, ?)')! defer { files_stmt.finalize() } for file in files { files_stmt.bind_text(1, save.id)! files_stmt.bind_text(2, file.path)! files_stmt.bind_text(3, file.hash)! files_stmt.bind_int(4, if file.executable { 1 } else { 0 })! files_stmt.step()! // A statement that has returned DONE must be reset before rebinding. files_stmt.reset() } } fn bind_optional(stmt &storage.Stmt, index int, value string) ! { if value == '' { stmt.bind_null(index)! } else { stmt.bind_text(index, value)! } } // save_id is the first 12 hex characters of the Blake3 hash of the canonical // record: parent, message, time, machine, checkpoint mark, agent, session and // the sorted file list. pub fn save_id(save Save, files []snapshot.FileEntry) string { return storage.hash_hex(canonical(save, files).bytes())[..12] } fn canonical(save Save, files []snapshot.FileEntry) string { mut sb := strings.Builder{} sb.write_string('koh3-save-v1\n') put_field(mut sb, 'parent', save.parent) put_field(mut sb, 'message', save.message) put_field(mut sb, 'created', save.created_at.str()) put_field(mut sb, 'machine', save.machine) put_field(mut sb, 'checkpoint', if save.is_checkpoint { '1' } else { '0' }) put_field(mut sb, 'agent', save.agent) put_field(mut sb, 'session', save.session) put_field(mut sb, 'files', files.len.str()) for file in files { mark := if file.executable { 'x' } else { '-' } put_field(mut sb, 'file', '${mark}${file.hash} ${file.path}') } return sb.str() } // Length-prefixed fields keep the record unambiguous whatever a message or a // path contains. fn put_field(mut sb strings.Builder, name string, value string) { sb.write_string('${name}:${value.len}:') sb.write_string(value) sb.write_string('\n') } fn tree_size(store &storage.Store, files []snapshot.FileEntry) i64 { mut total := i64(0) for file in files { total += store.object_size(file.hash) or { 0 } } return total }