history_test.v

9.8 KB · raw

1module history23import os4import src.snapshot5import src.storage67// Global rules must not make source fixtures depend on the developer’s home.8fn testsuite_begin() {9	os.setenv('HOME', os.join_path(os.temp_dir(), 'koh3-test-home-${os.getpid()}'), true)10}1112fn temp_repo(name string) !(storage.Store, string) {13	dir := os.join_path(os.temp_dir(), 'koh3_hist_${os.getpid()}_${name}')14	os.rmdir_all(dir) or {}15	os.mkdir_all(dir, os.MkdirParams{})!16	store := storage.create(dir)!17	return store, dir18}1920fn write_file(root string, rel string, text string) ! {21	full := os.join_path(root, rel)22	os.mkdir_all(os.dir(full), os.MkdirParams{})!23	os.write_file(full, text)!24}2526fn test_walk_then_save() {27	mut store, dir := temp_repo('save')!28	defer {29		store.close()30		os.rmdir_all(dir) or {}31	}32	write_file(dir, 'a.txt', 'hello\n')!33	write_file(dir, 'sub/b.txt', 'world\n')!34	entries := snapshot.walk(store, dir, snapshot.Ignore{})!35	assert entries.len == 23637	save := save_worktree(store, dir, snapshot.Ignore{}, SaveInput{38		message: 'first save'39	})!40	assert save.id.len == 1241	assert save.parent == ''42	assert head_id(store) == save.id4344	files := files_of(store, save.id)!45	assert files.len == 246	assert files[0].path == 'a.txt'47	assert files[1].path == 'sub/b.txt'4849	// Nothing changed, so a plain save refuses and a checkpoint records.50	if _ := save_worktree(store, dir, snapshot.Ignore{}, SaveInput{ message: 'again' }) {51		assert false, 'a save with no changes should refuse'52	} else {53		assert err.msg() == nothing_changed54	}55	second := save_worktree(store, dir, snapshot.Ignore{}, SaveInput{56		message:       'checked anyway'57		is_checkpoint: true58	})!59	assert second.parent == save.id60	assert second.is_checkpoint61}6263fn test_going_back_is_recorded_not_rewound() {64	mut store, dir := temp_repo('back')!65	defer {66		store.close()67		os.rmdir_all(dir) or {}68	}69	write_file(dir, 'a.txt', 'one\n')!70	first := save_worktree(store, dir, snapshot.Ignore{}, SaveInput{ message: 'one' })!71	write_file(dir, 'a.txt', 'two\n')!72	second := save_worktree(store, dir, snapshot.Ignore{}, SaveInput{ message: 'two' })!7374	// effective position follows restores, so undo keeps stepping back.75	assert effective(store)!.id == second.id76	first_files := files_of(store, first.id)!77	restore := save_tree(store, '', first_files, SaveInput{78		message:  'undo: ${first.id}'79		restores: first.id80	})!81	assert restore.parent == second.id82	assert restore.restores == first.id83	assert effective(store)!.id == first.id84	// Walking back from the first save has nowhere to go; undo says so rather85	// than looping.86	if _ := step_back(store, effective(store)!.id, 1) {87		assert false, 'there is nothing before the first save'88	} else {89		assert err.msg().contains('nothing before')90	}9192	// The tree at head is the restored one.93	assert same_files(store, head_id(store), first_files)!94}9596fn same_files(store &storage.Store, id string, expected []snapshot.FileEntry) !bool {97	actual := files_of(store, id)!98	if actual.len != expected.len {99		return false100	}101	for i in 0 .. actual.len {102		if actual[i].path != expected[i].path || actual[i].hash != expected[i].hash {103			return false104		}105	}106	return true107}108109fn test_resolve_accepts_a_unique_prefix() {110	mut store, dir := temp_repo('resolve')!111	defer {112		store.close()113		os.rmdir_all(dir) or {}114	}115	write_file(dir, 'a.txt', 'one\n')!116	first := save_worktree(store, dir, snapshot.Ignore{}, SaveInput{ message: 'one' })!117	assert resolve(store, first.id[..6])!.id == first.id118	if _ := resolve(store, 'abc') {119		assert false, 'a prefix shorter than six characters should be refused'120	} else {121		assert err.msg().contains('at least 6')122	}123	if _ := resolve(store, 'zzzzzz') {124		assert false, 'an unknown prefix should be refused'125	} else {126		assert err.msg().contains('no save starts with')127	}128}129130// A combine leaves the replayed-away originals in history but off the line.131// `load --checkpoint` and `diff --session` answer about the line.132fn test_checkpoint_and_session_lookups_stay_on_the_line() {133	mut store, dir := temp_repo('on_line')!134	defer {135		store.close()136		os.rmdir_all(dir) or {}137	}138	write_file(dir, 'a.txt', 'one\n')!139	kept := save_worktree(store, dir, snapshot.Ignore{}, SaveInput{140		message:       'on the line'141		session:       'work'142		is_checkpoint: true143	})!144	write_file(dir, 'a.txt', 'two\n')!145	save_worktree(store, dir, snapshot.Ignore{}, SaveInput{146		message:       'replayed away later'147		session:       'work'148		is_checkpoint: true149	})!150	// What a combine does to the line: head moves elsewhere, the save stays.151	set_head(store, kept.id)!152	assert latest_checkpoint(store)?.id == kept.id153	assert session_saves(store, 'work')!.map(it.id) == [kept.id]154}155156// A save walks the tree before it takes the lock. If another command moved157// head meanwhile — a steal or an undo whose files are still being written — the158// walk is stale, and recording it on top of the new head would quietly undo159// that command. The tree is read again under the lock instead, and a restore160// still writing files is refused.161fn test_a_save_whose_walk_went_stale_reads_the_tree_again() {162	mut store, dir := temp_repo('stale_walk')!163	defer {164		store.close()165		os.rmdir_all(dir) or {}166	}167	write_file(dir, 'a.txt', 'one\n')!168	first := save_worktree(store, dir, snapshot.Ignore{}, SaveInput{169		message: 'first'170	})!171	write_file(dir, 'b.txt', 'mine\n')!172	stale := snapshot.walk(store, dir, snapshot.Ignore{})!173	// Another command lands a save and its files between that walk and the lock.174	write_file(dir, 'a.txt', 'two\n')!175	other := save_tree(store, dir,176		snapshot.walk(store, dir, snapshot.Ignore{})!.filter(it.path == 'a.txt'), SaveInput{177			message: 'somebody else'178		})!179	store.begin()!180	saved := save_walk(store, dir, SaveInput{181		message: 'mine'182	}, first.id, stale)!183	store.end()!184	assert saved.parent == other.id185	a := files_of(store, saved.id)!.filter(it.path == 'a.txt')186	assert a.len == 1 && a[0].hash == storage.hash_hex('two\n'.bytes())187188	// A restore that moved head and is still writing files: no save on top.189	stale_again := snapshot.walk(store, dir, snapshot.Ignore{})!190	snapshot.mark_restore(store)!191	moved := save_tree(store, '', files_of(store, first.id)!, SaveInput{192		message:  'undo: ${first.id}'193		restores: first.id194	})!195	store.begin()!196	if _ := save_walk(store, dir, SaveInput{197		message: 'on a half tree'198	}, saved.id, stale_again)199	{200		assert false, 'a save must not land on a restore still writing files'201	} else {202		assert err.msg() == snapshot.restore_unfinished203	}204	store.rollback()205	assert head_id(store) == moved.id206}207208// Two agents saving the same shared tree: the one that waited is told whose209// save already holds its changes, not just that nothing changed.210fn test_a_save_beaten_to_the_same_tree_names_the_save_that_holds_it() {211	mut store, dir := temp_repo('beaten')!212	defer {213		store.close()214		os.rmdir_all(dir) or {}215	}216	write_file(dir, 'a.txt', 'one\n')!217	first := save_worktree(store, dir, snapshot.Ignore{}, SaveInput{218		message: 'first'219	})!220	write_file(dir, 'b.txt', 'both agents\n')!221	walked := snapshot.walk(store, dir, snapshot.Ignore{})!222	winner := save_worktree(store, dir, snapshot.Ignore{}, SaveInput{223		message: 'agent one'224	})!225	store.begin()!226	if _ := save_walk(store, dir, SaveInput{227		message: 'agent two'228	}, first.id, walked)229	{230		assert false, 'the same tree must not be saved twice'231	} else {232		assert err.msg().contains('[${winner.id}] "agent one"')233	}234	store.rollback()235}236237// The save may read head just after a restore moved it, so its walk is not238// stale but the files are still landing: the mark alone decides.239fn test_a_save_after_a_restore_moved_head_waits_for_its_files() {240	mut store, dir := temp_repo('after_restore')!241	defer {242		store.close()243		os.rmdir_all(dir) or {}244	}245	write_file(dir, 'a.txt', 'one\n')!246	first := save_worktree(store, dir, snapshot.Ignore{}, SaveInput{247		message: 'first'248	})!249	write_file(dir, 'a.txt', 'two\n')!250	save_worktree(store, dir, snapshot.Ignore{}, SaveInput{251		message: 'second'252	})!253	snapshot.mark_restore(store)!254	moved := save_tree(store, '', files_of(store, first.id)!, SaveInput{255		message:  'undo: ${first.id}'256		restores: first.id257	})!258	// Head read after the move, tree still the old one: not stale, but half.259	if _ := save_worktree(store, dir, snapshot.Ignore{}, SaveInput{260		message: 'on a half tree'261	})262	{263		assert false, 'a save must not land while a restore writes its files'264	} else {265		assert err.msg() == snapshot.restore_unfinished266	}267	assert head_id(store) == moved.id268}269270// A .kohignore change that lands with the other command must apply to the271// tree read again under the lock, or a newly ignored file would be saved.272fn test_a_stale_walk_is_read_again_with_the_current_ignore_rules() {273	mut store, dir := temp_repo('stale_rules')!274	defer {275		store.close()276		os.rmdir_all(dir) or {}277	}278	write_file(dir, 'a.txt', 'one\n')!279	first := save_worktree(store, dir, snapshot.Ignore{}, SaveInput{280		message: 'first'281	})!282	write_file(dir, 'secret.env', 'key\n')!283	stale := snapshot.walk(store, dir, snapshot.Ignore{})!284	write_file(dir, '.kohignore', 'secret.env\n')!285	save_tree(store, dir, snapshot.walk(store, dir, snapshot.load(dir))!, SaveInput{286		message: 'ignore the secret'287	})!288	write_file(dir, 'a.txt', 'two\n')!289	store.begin()!290	saved := save_walk(store, dir, SaveInput{291		message: 'mine'292	}, first.id, stale)!293	store.end()!294	assert !files_of(store, saved.id)!.any(it.path == 'secret.env')295}296297// Saves made within one second share a time; the all list must still show the298// newest first rather than ordering them by their hash.299fn test_all_saves_keeps_same_second_saves_newest_first() {300	mut store, dir := temp_repo('same_second')!301	defer {302		store.close()303		os.rmdir_all(dir) or {}304	}305	mut made := []string{}306	for i in 0 .. 6 {307		write_file(dir, 'a.txt', 'version ${i}\n')!308		made << save_worktree(store, dir, snapshot.Ignore{}, SaveInput{309			message:    'save ${i}'310			created_at: 1790000000311		})!.id312	}313	made.reverse_in_place()314	assert all_saves(store)!.map(it.id) == made315}