commit.v

6.4 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}2829pub const nothing_changed = 'nothing has changed since the last save'3031// commit_worktree walks the working tree and records it. A plain save refuses32// when the tree matches head; a checkpoint always records.33pub fn commit_worktree(store &storage.Store, root string, ig snapshot.Ignore, input SaveInput) !Save {34	files := snapshot.walk(store, root, ig)!35	if input.restores == '' && !input.is_checkpoint {36		if previous := head(store) {37			if snapshot.same_tree(files, files_of(store, previous.id)!) {38				return error(nothing_changed)39			}40		}41	}42	return commit_files(store, root, files, input)!43}4445// commit_files records an already-known tree. `root` is '' when the objects are46// already in the store — a restore, or a replay during a steal.47pub fn commit_files(store &storage.Store, root string, files []snapshot.FileEntry, input SaveInput) !Save {48	store.begin()!49	save := record_save(store, root, files, input) or {50		store.rollback()51		return err52	}53	store.commit()!54	write_log_md(store)!55	return save56}5758// record_save writes one save inside the caller's transaction. A steal uses it59// to land a whole replay — several saves, fetched objects and fetched events —60// in a single step, so a failure leaves no half-combined line behind.61pub fn record_save(store &storage.Store, root string, files []snapshot.FileEntry, input SaveInput) !Save {62	if root != '' {63		snapshot.store_objects(store, root, files)!64	}65	return write_save(store, files, input)!66}6768fn write_save(store &storage.Store, files []snapshot.FileEntry, input SaveInput) !Save {69	created_at := if input.created_at > 0 { input.created_at } else { time.now().unix() }70	mut save := Save{71		parent:        head_id(store)72		message:       input.message73		created_at:    created_at74		machine:       if input.machine != '' { input.machine } else { machine.name() }75		is_checkpoint: input.is_checkpoint76		size_bytes:    tree_size(store, files)77		agent:         input.agent78		session:       input.session79		restores:      input.restores80		replayed_from: input.replayed_from81	}82	// Two saves made in the same second with the same record would collide.83	// Bumping the clock keeps one line of history and keeps the ID unique.84	for {85		save.id = save_id(save, files)86		if by_id(store, save.id) == none {87			break88		}89		save.created_at++90	}91	insert_save(store, save, files)!92	store.set_state(storage.head_key, save.id)!93	return save94}9596// import_save records a save exactly as another machine made it: same ID, same97// parent, same time, same files. `steal` and `migrate` use it; a save made here98// goes through write_save instead, which mints the ID.99pub fn import_save(store &storage.Store, save Save, files []snapshot.FileEntry) ! {100	insert_save(store, save, files)!101}102103// set_head moves the line's head without creating a save. Only fetching another104// machine's saves does that: nothing is erased and nothing is re-recorded,105// because those saves are already on the line.106pub fn set_head(store &storage.Store, id string) ! {107	store.set_state(storage.head_key, id)!108}109110fn insert_save(store &storage.Store, save Save, files []snapshot.FileEntry) ! {111	mut stmt := store.db.prepare('INSERT INTO saves (id, parent, message, created_at, machine,112		is_checkpoint, size_bytes, agent, session, restores, replayed_from)113		VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')!114	stmt.bind_text(1, save.id)!115	if save.parent == '' {116		stmt.bind_null(2)!117	} else {118		stmt.bind_text(2, save.parent)!119	}120	stmt.bind_text(3, save.message)!121	stmt.bind_int(4, save.created_at)!122	stmt.bind_text(5, save.machine)!123	stmt.bind_int(6, if save.is_checkpoint { 1 } else { 0 })!124	stmt.bind_int(7, save.size_bytes)!125	bind_optional(stmt, 8, save.agent)!126	bind_optional(stmt, 9, save.session)!127	bind_optional(stmt, 10, save.restores)!128	bind_optional(stmt, 11, save.replayed_from)!129	stmt.step()!130	stmt.finalize()131132	mut files_stmt :=133		store.db.prepare('INSERT INTO save_files (save_id, path, hash, executable) VALUES (?, ?, ?, ?)')!134	defer {135		files_stmt.finalize()136	}137	for file in files {138		files_stmt.bind_text(1, save.id)!139		files_stmt.bind_text(2, file.path)!140		files_stmt.bind_text(3, file.hash)!141		files_stmt.bind_int(4, if file.executable { 1 } else { 0 })!142		files_stmt.step()!143		// A statement that has returned DONE must be reset before rebinding.144		files_stmt.reset()145	}146}147148fn bind_optional(stmt &storage.Stmt, index int, value string) ! {149	if value == '' {150		stmt.bind_null(index)!151	} else {152		stmt.bind_text(index, value)!153	}154}155156// save_id is the first 12 hex characters of the Blake3 hash of the canonical157// record: parent, message, time, machine, checkpoint mark, agent, session and158// the sorted file list.159pub fn save_id(save Save, files []snapshot.FileEntry) string {160	return storage.hash_hex(canonical(save, files).bytes())[..12]161}162163fn canonical(save Save, files []snapshot.FileEntry) string {164	mut sb := strings.Builder{}165	sb.write_string('koh3-save-v1\n')166	put_field(mut sb, 'parent', save.parent)167	put_field(mut sb, 'message', save.message)168	put_field(mut sb, 'created', save.created_at.str())169	put_field(mut sb, 'machine', save.machine)170	put_field(mut sb, 'checkpoint', if save.is_checkpoint { '1' } else { '0' })171	put_field(mut sb, 'agent', save.agent)172	put_field(mut sb, 'session', save.session)173	put_field(mut sb, 'files', files.len.str())174	for file in files {175		mark := if file.executable { 'x' } else { '-' }176		put_field(mut sb, 'file', '${mark}${file.hash} ${file.path}')177	}178	return sb.str()179}180181// Length-prefixed fields keep the record unambiguous whatever a message or a182// path contains.183fn put_field(mut sb strings.Builder, name string, value string) {184	sb.write_string('${name}:${value.len}:')185	sb.write_string(value)186	sb.write_string('\n')187}188189fn tree_size(store &storage.Store, files []snapshot.FileEntry) i64 {190	mut total := i64(0)191	for file in files {192		total += store.object_size(file.hash) or { 0 }193	}194	return total195}