module history import os import src.snapshot import src.storage fn temp_repo(name string) !(storage.Store, string) { dir := os.join_path(os.temp_dir(), 'koh3_hist_${os.getpid()}_${name}') os.rmdir_all(dir) or {} os.mkdir_all(dir, os.MkdirParams{})! store := storage.create(dir)! return store, dir } fn write_file(root string, rel string, text string) ! { full := os.join_path(root, rel) os.mkdir_all(os.dir(full), os.MkdirParams{})! os.write_file(full, text)! } fn test_walk_then_save() { mut store, dir := temp_repo('save')! defer { store.close() os.rmdir_all(dir) or {} } write_file(dir, 'a.txt', 'hello\n')! write_file(dir, 'sub/b.txt', 'world\n')! entries := snapshot.walk(store, dir, snapshot.Ignore{})! assert entries.len == 2 save := save_worktree(store, dir, snapshot.Ignore{}, SaveInput{ message: 'first save' })! assert save.id.len == 12 assert save.parent == '' assert head_id(store) == save.id files := files_of(store, save.id)! assert files.len == 2 assert files[0].path == 'a.txt' assert files[1].path == 'sub/b.txt' // Nothing changed, so a plain save refuses and a checkpoint records. if _ := save_worktree(store, dir, snapshot.Ignore{}, SaveInput{ message: 'again' }) { assert false, 'a save with no changes should refuse' } else { assert err.msg() == nothing_changed } second := save_worktree(store, dir, snapshot.Ignore{}, SaveInput{ message: 'checked anyway' is_checkpoint: true })! assert second.parent == save.id assert second.is_checkpoint } fn test_going_back_is_recorded_not_rewound() { mut store, dir := temp_repo('back')! defer { store.close() os.rmdir_all(dir) or {} } write_file(dir, 'a.txt', 'one\n')! first := save_worktree(store, dir, snapshot.Ignore{}, SaveInput{ message: 'one' })! write_file(dir, 'a.txt', 'two\n')! second := save_worktree(store, dir, snapshot.Ignore{}, SaveInput{ message: 'two' })! // effective position follows restores, so undo keeps stepping back. assert effective(store)!.id == second.id first_files := files_of(store, first.id)! restore := save_tree(store, '', first_files, SaveInput{ message: 'undo: ${first.id}' restores: first.id })! assert restore.parent == second.id assert restore.restores == first.id assert effective(store)!.id == first.id // Walking back from the first save has nowhere to go; undo says so rather // than looping. if _ := step_back(store, effective(store)!.id, 1) { assert false, 'there is nothing before the first save' } else { assert err.msg().contains('nothing before') } // The tree at head is the restored one. assert same_files(store, head_id(store), first_files)! } fn same_files(store &storage.Store, id string, expected []snapshot.FileEntry) !bool { actual := files_of(store, id)! if actual.len != expected.len { return false } for i in 0 .. actual.len { if actual[i].path != expected[i].path || actual[i].hash != expected[i].hash { return false } } return true } fn test_resolve_accepts_a_unique_prefix() { mut store, dir := temp_repo('resolve')! defer { store.close() os.rmdir_all(dir) or {} } write_file(dir, 'a.txt', 'one\n')! first := save_worktree(store, dir, snapshot.Ignore{}, SaveInput{ message: 'one' })! assert resolve(store, first.id[..6])!.id == first.id if _ := resolve(store, 'abc') { assert false, 'a prefix shorter than six characters should be refused' } else { assert err.msg().contains('at least 6') } if _ := resolve(store, 'zzzzzz') { assert false, 'an unknown prefix should be refused' } else { assert err.msg().contains('no save starts with') } } // A combine leaves the replayed-away originals in history but off the line. // `load --checkpoint` and `diff --session` answer about the line. fn test_checkpoint_and_session_lookups_stay_on_the_line() { mut store, dir := temp_repo('on_line')! defer { store.close() os.rmdir_all(dir) or {} } write_file(dir, 'a.txt', 'one\n')! kept := save_worktree(store, dir, snapshot.Ignore{}, SaveInput{ message: 'on the line' session: 'work' is_checkpoint: true })! write_file(dir, 'a.txt', 'two\n')! save_worktree(store, dir, snapshot.Ignore{}, SaveInput{ message: 'replayed away later' session: 'work' is_checkpoint: true })! // What a combine does to the line: head moves elsewhere, the save stays. set_head(store, kept.id)! assert latest_checkpoint(store)?.id == kept.id assert session_saves(store, 'work')!.map(it.id) == [kept.id] } // A save walks the tree before it takes the lock. If another command moved // head meanwhile — a steal or an undo whose files are still being written — the // walk is stale, and recording it on top of the new head would quietly undo // that command. The tree is read again under the lock instead, and a restore // still writing files is refused. fn test_a_save_whose_walk_went_stale_reads_the_tree_again() { mut store, dir := temp_repo('stale_walk')! defer { store.close() os.rmdir_all(dir) or {} } write_file(dir, 'a.txt', 'one\n')! first := save_worktree(store, dir, snapshot.Ignore{}, SaveInput{ message: 'first' })! write_file(dir, 'b.txt', 'mine\n')! stale := snapshot.walk(store, dir, snapshot.Ignore{})! // Another command lands a save and its files between that walk and the lock. write_file(dir, 'a.txt', 'two\n')! other := save_tree(store, dir, snapshot.walk(store, dir, snapshot.Ignore{})!.filter(it.path == 'a.txt'), SaveInput{ message: 'somebody else' })! store.begin()! saved := save_walk(store, dir, SaveInput{ message: 'mine' }, first.id, stale)! store.end()! assert saved.parent == other.id a := files_of(store, saved.id)!.filter(it.path == 'a.txt') assert a.len == 1 && a[0].hash == storage.hash_hex('two\n'.bytes()) // A restore that moved head and is still writing files: no save on top. stale_again := snapshot.walk(store, dir, snapshot.Ignore{})! snapshot.mark_restore(store)! moved := save_tree(store, '', files_of(store, first.id)!, SaveInput{ message: 'undo: ${first.id}' restores: first.id })! store.begin()! if _ := save_walk(store, dir, SaveInput{ message: 'on a half tree' }, saved.id, stale_again) { assert false, 'a save must not land on a restore still writing files' } else { assert err.msg() == snapshot.restore_unfinished } store.rollback() assert head_id(store) == moved.id } // Two agents saving the same shared tree: the one that waited is told whose // save already holds its changes, not just that nothing changed. fn test_a_save_beaten_to_the_same_tree_names_the_save_that_holds_it() { mut store, dir := temp_repo('beaten')! defer { store.close() os.rmdir_all(dir) or {} } write_file(dir, 'a.txt', 'one\n')! first := save_worktree(store, dir, snapshot.Ignore{}, SaveInput{ message: 'first' })! write_file(dir, 'b.txt', 'both agents\n')! walked := snapshot.walk(store, dir, snapshot.Ignore{})! winner := save_worktree(store, dir, snapshot.Ignore{}, SaveInput{ message: 'agent one' })! store.begin()! if _ := save_walk(store, dir, SaveInput{ message: 'agent two' }, first.id, walked) { assert false, 'the same tree must not be saved twice' } else { assert err.msg().contains('[${winner.id}] "agent one"') } store.rollback() } // The save may read head just after a restore moved it, so its walk is not // stale but the files are still landing: the mark alone decides. fn test_a_save_after_a_restore_moved_head_waits_for_its_files() { mut store, dir := temp_repo('after_restore')! defer { store.close() os.rmdir_all(dir) or {} } write_file(dir, 'a.txt', 'one\n')! first := save_worktree(store, dir, snapshot.Ignore{}, SaveInput{ message: 'first' })! write_file(dir, 'a.txt', 'two\n')! save_worktree(store, dir, snapshot.Ignore{}, SaveInput{ message: 'second' })! snapshot.mark_restore(store)! moved := save_tree(store, '', files_of(store, first.id)!, SaveInput{ message: 'undo: ${first.id}' restores: first.id })! // Head read after the move, tree still the old one: not stale, but half. if _ := save_worktree(store, dir, snapshot.Ignore{}, SaveInput{ message: 'on a half tree' }) { assert false, 'a save must not land while a restore writes its files' } else { assert err.msg() == snapshot.restore_unfinished } assert head_id(store) == moved.id } // A .kohignore change that lands with the other command must apply to the // tree read again under the lock, or a newly ignored file would be saved. fn test_a_stale_walk_is_read_again_with_the_current_ignore_rules() { mut store, dir := temp_repo('stale_rules')! defer { store.close() os.rmdir_all(dir) or {} } write_file(dir, 'a.txt', 'one\n')! first := save_worktree(store, dir, snapshot.Ignore{}, SaveInput{ message: 'first' })! write_file(dir, 'secret.env', 'key\n')! stale := snapshot.walk(store, dir, snapshot.Ignore{})! write_file(dir, '.kohignore', 'secret.env\n')! save_tree(store, dir, snapshot.walk(store, dir, snapshot.load(dir))!, SaveInput{ message: 'ignore the secret' })! write_file(dir, 'a.txt', 'two\n')! store.begin()! saved := save_walk(store, dir, SaveInput{ message: 'mine' }, first.id, stale)! store.end()! assert !files_of(store, saved.id)!.any(it.path == 'secret.env') } // Saves made within one second share a time; the all list must still show the // newest first rather than ordering them by their hash. fn test_all_saves_keeps_same_second_saves_newest_first() { mut store, dir := temp_repo('same_second')! defer { store.close() os.rmdir_all(dir) or {} } mut made := []string{} for i in 0 .. 6 { write_file(dir, 'a.txt', 'version ${i}\n')! made << save_worktree(store, dir, snapshot.Ignore{}, SaveInput{ message: 'save ${i}' created_at: 1790000000 })!.id } made.reverse_in_place() assert all_saves(store)!.map(it.id) == made }