save.v

8.1 KB · raw

1//! Creating saves.2//!3//! A save is a full snapshot: every tracked path with its object hash and4//! executable bit, plus the message, time, machine and optional agent and5//! session. Its ID is the first 12 hex characters of the Blake3 hash of the6//! canonical record, computed once, at creation, and never recomputed — which7//! is why `strip` can change a save's files and keep its ID.89module 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	// storing hears how many of the tree's files have been stored.28	storing snapshot.Counted = unsafe { nil }29}3031pub const nothing_changed = 'nothing has changed since the last save'3233// save_worktree walks the working tree and records it. A plain save refuses34// when the tree matches head; a checkpoint always records. The walk runs35// before the lock, so other commands are not held up by the hashing.36pub fn save_worktree(store &storage.Store, root string, ig snapshot.Ignore, input SaveInput) !Save {37	walked_head := head_id(store)38	files := snapshot.walk(store, root, ig)!39	store.begin()!40	save := save_walk(store, root, input, walked_head, files) or {41		store.rollback()42		return err43	}44	store.end()!45	refresh_log_md(store)46	return save47}4849// save_walk records a walk inside the caller's transaction. A restore still50// writing its files is refused: the tree is half one save and half another.51// When another command moved head after the walk, the walk is stale, and52// recording it on the new head would quietly undo that command, so the tree53// (and its ignore rules, which may have changed too) is read again under the54// lock.55fn save_walk(store &storage.Store, root string, input SaveInput, walked_head string, walked []snapshot.FileEntry) !Save {56	if snapshot.restore_pending(store) {57		return error(snapshot.restore_unfinished)58	}59	mut files := walked.clone()60	moved := head_id(store) != walked_head61	if moved {62		files = snapshot.walk(store, root, snapshot.load(root))!63	}64	if input.restores == '' && !input.is_checkpoint {65		if previous := head(store) {66			if snapshot.same_tree(files, files_of(store, previous.id)!) {67				if moved {68					// Another agent's save, made while this one waited for the69					// lock, already holds these changes.70					return error('${nothing_changed} — [${previous.id}] "${previous.message}" was saved a moment ago and already holds this tree')71				}72				return error(nothing_changed)73			}74		}75	}76	return record_save(store, root, files, input)!77}7879// save_tree records an already-known tree. `root` is '' when the objects are80// already in the store — a restore, or a replay during a steal.81pub fn save_tree(store &storage.Store, root string, files []snapshot.FileEntry, input SaveInput) !Save {82	store.begin()!83	save := record_save(store, root, files, input) or {84		store.rollback()85		return err86	}87	store.end()!88	refresh_log_md(store)89	return save90}9192// record_save writes one save inside the caller's transaction. A steal uses it93// to land a whole replay — several saves, fetched objects and fetched events —94// in a single step, so a failure leaves no half-combined line behind.95pub fn record_save(store &storage.Store, root string, files []snapshot.FileEntry, input SaveInput) !Save {96	if root != '' {97		snapshot.store_objects(store, root, files, input.storing)!98	}99	return write_save(store, files, input)!100}101102// write_save mints the ID, inserts the save and then moves head, inside the caller's transaction, so103// the save's rows and head change together or not at all. The objects were stored before (record_save).104fn write_save(store &storage.Store, files []snapshot.FileEntry, input SaveInput) !Save {105	created_at := if input.created_at > 0 { input.created_at } else { time.now().unix() }106	mut save := Save{107		parent:        head_id(store)108		message:       input.message109		created_at:    created_at110		machine:       if input.machine != '' { input.machine } else { machine.name() }111		is_checkpoint: input.is_checkpoint112		size_bytes:    tree_size(store, files)113		agent:         input.agent114		session:       input.session115		restores:      input.restores116		replayed_from: input.replayed_from117	}118	// Two saves made in the same second with the same record would collide.119	// Bumping the clock keeps one line of history and keeps the ID unique.120	for {121		save.id = save_id(save, files)122		if by_id(store, save.id) == none {123			break124		}125		save.created_at++126	}127	insert_save(store, save, files)!128	store.set_state(storage.head_key, save.id)!129	return save130}131132// import_save records a save exactly as another machine made it: same ID, same133// parent, same time, same files. `steal` and `migrate` use it; a save made here134// goes through write_save instead, which mints the ID.135pub fn import_save(store &storage.Store, save Save, files []snapshot.FileEntry) ! {136	insert_save(store, save, files)!137}138139// set_head moves the line's head without creating a save. Only fetching another140// machine's saves does that: nothing is erased and nothing is re-recorded,141// because those saves are already on the line.142pub fn set_head(store &storage.Store, id string) ! {143	store.set_state(storage.head_key, id)!144}145146// insert_save writes the save row, then one row per file. Its callers hold the transaction.147fn insert_save(store &storage.Store, save Save, files []snapshot.FileEntry) ! {148	mut stmt := store.db.prepare('INSERT INTO saves (id, parent, message, created_at, machine,149		is_checkpoint, size_bytes, agent, session, restores, replayed_from)150		VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')!151	stmt.bind_text(1, save.id)!152	if save.parent == '' {153		stmt.bind_null(2)!154	} else {155		stmt.bind_text(2, save.parent)!156	}157	stmt.bind_text(3, save.message)!158	stmt.bind_int(4, save.created_at)!159	stmt.bind_text(5, save.machine)!160	stmt.bind_int(6, if save.is_checkpoint { 1 } else { 0 })!161	stmt.bind_int(7, save.size_bytes)!162	bind_optional(stmt, 8, save.agent)!163	bind_optional(stmt, 9, save.session)!164	bind_optional(stmt, 10, save.restores)!165	bind_optional(stmt, 11, save.replayed_from)!166	stmt.step()!167	stmt.finalize()168169	mut files_stmt :=170		store.db.prepare('INSERT INTO save_files (save_id, path, hash, executable) VALUES (?, ?, ?, ?)')!171	defer {172		files_stmt.finalize()173	}174	for file in files {175		files_stmt.bind_text(1, save.id)!176		files_stmt.bind_text(2, file.path)!177		files_stmt.bind_text(3, file.hash)!178		files_stmt.bind_int(4, if file.executable { 1 } else { 0 })!179		files_stmt.step()!180		// A statement that has returned DONE must be reset before rebinding.181		files_stmt.reset()182	}183}184185fn bind_optional(stmt &storage.Stmt, index int, value string) ! {186	if value == '' {187		stmt.bind_null(index)!188	} else {189		stmt.bind_text(index, value)!190	}191}192193// save_id is the first 12 hex characters of the Blake3 hash of the canonical194// record: parent, message, time, machine, checkpoint mark, agent, session and195// the sorted file list.196pub fn save_id(save Save, files []snapshot.FileEntry) string {197	return storage.hash_hex(canonical(save, files).bytes())[..12]198}199200fn canonical(save Save, files []snapshot.FileEntry) string {201	mut sb := strings.Builder{}202	sb.write_string('koh3-save-v1\n')203	put_field(mut sb, 'parent', save.parent)204	put_field(mut sb, 'message', save.message)205	put_field(mut sb, 'created', save.created_at.str())206	put_field(mut sb, 'machine', save.machine)207	put_field(mut sb, 'checkpoint', if save.is_checkpoint { '1' } else { '0' })208	put_field(mut sb, 'agent', save.agent)209	put_field(mut sb, 'session', save.session)210	put_field(mut sb, 'files', files.len.str())211	for file in files {212		mark := if file.executable { 'x' } else { '-' }213		put_field(mut sb, 'file', '${mark}${file.hash} ${file.path}')214	}215	return sb.str()216}217218// Length-prefixed fields keep the record unambiguous whatever a message or a219// path contains.220fn put_field(mut sb strings.Builder, name string, value string) {221	sb.write_string('${name}:${value.len}:')222	sb.write_string(value)223	sb.write_string('\n')224}225226fn tree_size(store &storage.Store, files []snapshot.FileEntry) i64 {227	mut total := i64(0)228	for file in files {229		total += store.object_size(file.hash) or { 0 }230	}231	return total232}