diff --git a/Justfile b/Justfile index 9ee01f6..741c1ac 100644 --- a/Justfile +++ b/Justfile @@ -12,7 +12,7 @@ release: install: cargo install --path . -test: test-unit test-integration +test: test-unit test-integration test-e2e test-unit: cargo test --lib --bins @@ -20,6 +20,18 @@ test-unit: test-integration: cargo test --test "*" +e2e-venv: + cd ./e2e_tests \ + && python3 -m venv venv \ + && . ./venv/bin/activate \ + && pip --disable-pip-version-check install -r ./requirements.txt >/dev/null + + +test-e2e: e2e-venv release + cd ./e2e_tests \ + && . ./venv/bin/activate \ + && python -m pytest . + update-dependencies: @cd ./depcheck \ && python3 -m venv ./venv \ diff --git a/e2e_tests/.gitignore b/e2e_tests/.gitignore new file mode 100644 index 0000000..e79509f --- /dev/null +++ b/e2e_tests/.gitignore @@ -0,0 +1,2 @@ +/venv/ +/__pycache__/ diff --git a/e2e_tests/helpers.py b/e2e_tests/helpers.py new file mode 100644 index 0000000..810c158 --- /dev/null +++ b/e2e_tests/helpers.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python3 + +import os +import os.path +import subprocess +import tempfile +import hashlib + +import git + +binary = os.path.join( + os.path.dirname(os.path.dirname(os.path.realpath(__file__))), "target/release/grm" +) + + +def grm(args, cwd=None, is_invalid=False): + cmd = subprocess.run([binary] + args, cwd=cwd, capture_output=True, text=True) + if not is_invalid: + assert "USAGE" not in cmd.stderr + print(f"grmcmd: {args}") + print(f"stdout:\n{cmd.stdout}") + print(f"stderr:\n{cmd.stderr}") + assert "panicked" not in cmd.stderr + return cmd + + +def shell(script): + script = "set -o errexit\nset -o nounset\n" + script + subprocess.run(["bash"], input=script, text=True, check=True) + + +def checksum_directory(path): + """ + Gives a "checksum" of a directory that includes all files & directories + recursively, including owner/group/permissions. Useful to compare that a + directory did not change after a command was run. + + The following makes it a bit complicated: + + > Whether or not the lists are sorted depends on the file system. + + - https://docs.python.org/3/library/os.html#os.walk + + This means we have to first get a list of all hashes of files and + directories, then sort the hashes and then create the hash for the whole + directory. + """ + path = os.path.realpath(path) + + hashes = [] + + if not os.path.exists(path): + raise f"{path} not found" + + def get_stat_hash(path): + stat = bytes(str(os.stat(path).__hash__()), "ascii") + return stat + + for root, dirs, files in os.walk(path): + for file in files: + checksum = hashlib.md5() + filepath = os.path.join(root, file) + checksum.update(str.encode(filepath)) + checksum.update(get_stat_hash(filepath)) + with open(filepath, "rb") as f: + while True: + data = f.read(8192) + if not data: + break + checksum.update(data) + hashes.append(checksum.digest()) + + for d in dirs: + checksum = hashlib.md5() + dirpath = os.path.join(root, d) + checksum.update(get_stat_hash(dirpath)) + hashes.append(checksum.digest()) + + checksum = hashlib.md5() + for c in sorted(hashes): + checksum.update(c) + return checksum.hexdigest() + + +class TempGitRepository: + def __init__(self, dir=None): + self.dir = dir + pass + + def __enter__(self): + self.tmpdir = tempfile.TemporaryDirectory(dir=self.dir) + self.remote_1_dir = tempfile.TemporaryDirectory() + self.remote_2_dir = tempfile.TemporaryDirectory() + shell( + f""" + cd {self.tmpdir.name} + git init + echo test > test + git add test + git commit -m "commit1" + git remote add origin file://{self.remote_1_dir.name} + git remote add otherremote file://{self.remote_2_dir.name} + """ + ) + return self.tmpdir.name + + def __exit__(self, exc_type, exc_val, exc_tb): + del self.tmpdir + del self.remote_1_dir + del self.remote_2_dir + + +class TempGitRepositoryWorktree: + def __init__(self): + pass + + def __enter__(self): + self.tmpdir = tempfile.TemporaryDirectory() + self.remote_1_dir = tempfile.TemporaryDirectory() + self.remote_2_dir = tempfile.TemporaryDirectory() + shell( + f""" + cd {self.remote_1_dir.name} + git init --bare + """ + ) + shell( + f""" + cd {self.remote_2_dir.name} + git init --bare + """ + ) + shell( + f""" + cd {self.tmpdir.name} + git init + echo test > test + git add test + git commit -m "commit1" + echo test > test2 + git add test2 + git commit -m "commit2" + git remote add origin file://{self.remote_1_dir.name} + git remote add otherremote file://{self.remote_2_dir.name} + git ls-files | xargs rm -rf + mv .git .git-main-working-tree + git --git-dir .git-main-working-tree config core.bare true + """ + ) + return self.tmpdir.name + + def __exit__(self, exc_type, exc_val, exc_tb): + del self.tmpdir + del self.remote_1_dir + del self.remote_2_dir + + +class EmptyDir: + def __init__(self): + pass + + def __enter__(self): + self.tmpdir = tempfile.TemporaryDirectory() + return self.tmpdir.name + + def __exit__(self, exc_type, exc_val, exc_tb): + del self.tmpdir + + +class NonGitDir: + def __init__(self): + pass + + def __enter__(self): + self.tmpdir = tempfile.TemporaryDirectory() + shell( + f""" + cd {self.tmpdir.name} + mkdir testdir + touch testdir/test + touch test2 + """ + ) + return self.tmpdir.name + + def __exit__(self, exc_type, exc_val, exc_tb): + del self.tmpdir + + +class TempGitFileRemote: + def __init__(self): + pass + + def __enter__(self): + self.tmpdir = tempfile.TemporaryDirectory() + shell( + f""" + cd {self.tmpdir.name} + git init + echo test > test + git add test + git commit -m "commit1" + echo test > test2 + git add test2 + git commit -m "commit2" + git ls-files | xargs rm -rf + mv .git/* . + git config core.bare true + """ + ) + head_commit_sha = git.Repo(self.tmpdir.name).head.commit.hexsha + return (self.tmpdir.name, head_commit_sha) + + def __exit__(self, exc_type, exc_val, exc_tb): + del self.tmpdir + + +class NonExistentPath: + def __init__(self): + pass + + def __enter__(self): + self.dir = "/doesnotexist" + if os.path.exists(self.dir): + raise f"{self.dir} exists for some reason" + return self.dir + + def __exit__(self, exc_type, exc_val, exc_tb): + pass diff --git a/e2e_tests/requirements.txt b/e2e_tests/requirements.txt new file mode 100644 index 0000000..dc4e0b7 --- /dev/null +++ b/e2e_tests/requirements.txt @@ -0,0 +1,12 @@ +attrs==21.2.0 +gitdb==4.0.9 +GitPython==3.1.24 +iniconfig==1.1.1 +packaging==21.3 +pluggy==1.0.0 +py==1.11.0 +pyparsing==3.0.6 +pytest==6.2.5 +smmap==5.0.0 +toml==0.10.2 +typing-extensions==4.0.0 diff --git a/e2e_tests/test_basic.py b/e2e_tests/test_basic.py new file mode 100644 index 0000000..fc2ae9c --- /dev/null +++ b/e2e_tests/test_basic.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python3 + +from helpers import * + + +def test_invalid_command(): + cmd = grm(["whatever"], is_invalid=True) + assert "USAGE" in cmd.stderr + + +def test_help(): + cmd = grm(["--help"]) + assert "USAGE" in cmd.stdout diff --git a/e2e_tests/test_repos_find.py b/e2e_tests/test_repos_find.py new file mode 100644 index 0000000..0608e28 --- /dev/null +++ b/e2e_tests/test_repos_find.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 + +import tempfile + +import toml + +from helpers import * + + +def test_repos_find_nonexistent(): + with NonExistentPath() as nonexistent_dir: + cmd = grm(["repos", "find", nonexistent_dir]) + assert "does not exist" in cmd.stderr.lower() + assert cmd.returncode != 0 + assert not os.path.exists(nonexistent_dir) + + +def test_repos_find_file(): + with tempfile.NamedTemporaryFile() as tmpfile: + cmd = grm(["repos", "find", tmpfile.name]) + assert "not a directory" in cmd.stderr.lower() + assert cmd.returncode != 0 + + +def test_repos_find_empty(): + with tempfile.TemporaryDirectory() as tmpdir: + cmd = grm(["repos", "find", tmpdir]) + assert cmd.returncode == 0 + assert len(cmd.stdout) == 0 + assert len(cmd.stderr) != 0 + + +def test_repos_find_non_git_repos(): + with tempfile.TemporaryDirectory() as tmpdir: + shell( + f""" + cd {tmpdir} + mkdir non_git + ( + cd ./non_git + echo test > test + ) + """ + ) + + cmd = grm(["repos", "find", tmpdir]) + + assert cmd.returncode == 0 + assert len(cmd.stdout) == 0 + assert len(cmd.stderr) != 0 + + +def test_repos_find(): + with tempfile.TemporaryDirectory() as tmpdir: + shell( + f""" + cd {tmpdir} + mkdir repo1 + ( + cd ./repo1 + git init + echo test > test + git add test + git commit -m "commit1" + git remote add origin https://example.com/repo2.git + git remote add someremote ssh://example.com/repo2.git + ) + mkdir repo2 + ( + cd ./repo2 + git init + git co -b main + echo test > test + git add test + git commit -m "commit1" + git remote add origin https://example.com/repo2.git + ) + mkdir non_git + ( + cd non_git + echo test > test + ) + """ + ) + + cmd = grm(["repos", "find", tmpdir]) + assert cmd.returncode == 0 + assert len(cmd.stderr) == 0 + + output = toml.loads(cmd.stdout) + + assert isinstance(output, dict) + assert set(output.keys()) == {"trees"} + assert isinstance(output["trees"], list) + assert len(output["trees"]) == 1 + for tree in output["trees"]: + assert set(tree.keys()) == {"root", "repos"} + assert tree["root"] == tmpdir + assert isinstance(tree["repos"], list) + assert len(tree["repos"]) == 2 + + repo1 = [r for r in tree["repos"] if r["name"] == "repo1"][0] + assert repo1["worktree_setup"] is False + assert isinstance(repo1["remotes"], list) + assert len(repo1["remotes"]) == 2 + + origin = [r for r in repo1["remotes"] if r["name"] == "origin"][0] + assert set(origin.keys()) == {"name", "type", "url"} + assert origin["type"] == "https" + assert origin["url"] == "https://example.com/repo2.git" + + someremote = [r for r in repo1["remotes"] if r["name"] == "someremote"][0] + assert set(origin.keys()) == {"name", "type", "url"} + assert someremote["type"] == "ssh" + assert someremote["url"] == "ssh://example.com/repo2.git" + + repo2 = [r for r in tree["repos"] if r["name"] == "repo2"][0] + assert repo2["worktree_setup"] is False + assert isinstance(repo1["remotes"], list) + assert len(repo2["remotes"]) == 1 + + origin = [r for r in repo2["remotes"] if r["name"] == "origin"][0] + assert set(origin.keys()) == {"name", "type", "url"} + assert origin["type"] == "https" + assert origin["url"] == "https://example.com/repo2.git" + + +def test_repos_find_in_root(): + with TempGitRepository() as repo_dir: + + cmd = grm(["repos", "find", repo_dir]) + assert cmd.returncode == 0 + assert len(cmd.stderr) == 0 + + output = toml.loads(cmd.stdout) + + assert isinstance(output, dict) + assert set(output.keys()) == {"trees"} + assert isinstance(output["trees"], list) + assert len(output["trees"]) == 1 + for tree in output["trees"]: + assert set(tree.keys()) == {"root", "repos"} + assert tree["root"] == os.path.dirname(repo_dir) + assert isinstance(tree["repos"], list) + assert len(tree["repos"]) == 1 + + repo1 = [ + r for r in tree["repos"] if r["name"] == os.path.basename(repo_dir) + ][0] + assert repo1["worktree_setup"] is False + assert isinstance(repo1["remotes"], list) + assert len(repo1["remotes"]) == 2 + + origin = [r for r in repo1["remotes"] if r["name"] == "origin"][0] + assert set(origin.keys()) == {"name", "type", "url"} + assert origin["type"] == "file" + + someremote = [r for r in repo1["remotes"] if r["name"] == "otherremote"][0] + assert set(origin.keys()) == {"name", "type", "url"} + assert someremote["type"] == "file" diff --git a/e2e_tests/test_repos_sync.py b/e2e_tests/test_repos_sync.py new file mode 100644 index 0000000..62e0cca --- /dev/null +++ b/e2e_tests/test_repos_sync.py @@ -0,0 +1,707 @@ +#!/usr/bin/env python3 + +import tempfile +import re + +import pytest +import toml +import git + +from helpers import * + + +def test_repos_sync_config_is_valid_symlink(): + with tempfile.TemporaryDirectory() as target: + with TempGitFileRemote() as (remote, head_commit_sha): + with tempfile.NamedTemporaryFile() as config: + with tempfile.TemporaryDirectory() as config_dir: + config_symlink = os.path.join(config_dir, "cfglink") + os.symlink(config.name, config_symlink) + + with open(config.name, "w") as f: + f.write( + f""" + [[trees]] + root = "{target}" + + [[trees.repos]] + name = "test" + + [[trees.repos.remotes]] + name = "origin" + url = "file://{remote}" + type = "file" + """ + ) + + cmd = grm(["repos", "sync", "--config", config_symlink]) + assert cmd.returncode == 0 + + git_dir = os.path.join(target, "test") + assert os.path.exists(git_dir) + with git.Repo(git_dir) as repo: + assert not repo.bare + assert not repo.is_dirty() + assert set([str(r) for r in repo.remotes]) == {"origin"} + assert str(repo.active_branch) == "master" + assert str(repo.head.commit) == head_commit_sha + + +def test_repos_sync_config_is_invalid_symlink(): + with tempfile.TemporaryDirectory() as target: + with tempfile.TemporaryDirectory() as config_dir: + with NonExistentPath() as nonexistent_dir: + config_symlink = os.path.join(config_dir, "cfglink") + os.symlink(nonexistent_dir, config_symlink) + + cmd = grm(["repos", "sync", "--config", config_symlink]) + + assert cmd.returncode != 0 + assert len(cmd.stdout) == 0 + assert "not found" in cmd.stderr.lower() + assert not os.path.exists(os.path.join(target, "test")) + assert not os.path.exists(os.path.join(target, "test")) + + +def test_repos_sync_config_is_directory(): + with tempfile.TemporaryDirectory() as config: + cmd = grm(["repos", "sync", "--config", config]) + + assert cmd.returncode != 0 + assert len(cmd.stdout) == 0 + assert "is a directory" in cmd.stderr.lower() + + +def test_repos_sync_config_is_unreadable(): + with tempfile.TemporaryDirectory() as config_dir: + config_path = os.path.join(config_dir, "cfg") + open(config_path, "w") + os.chmod(config_path, 0o0000) + cmd = grm(["repos", "sync", "--config", config_path]) + + assert os.path.exists(config_path) + assert cmd.returncode != 0 + assert len(cmd.stdout) == 0 + assert "permission denied" in cmd.stderr.lower() + + +def test_repos_sync_unmanaged_repos(): + with tempfile.TemporaryDirectory() as root: + with TempGitRepository(dir=root) as unmanaged_repo: + with tempfile.NamedTemporaryFile() as config: + with open(config.name, "w") as f: + f.write( + f""" + [[trees]] + root = "{root}" + + [[trees.repos]] + name = "test" + """ + ) + + cmd = grm(["repos", "sync", "--config", config.name]) + assert cmd.returncode == 0 + + git_dir = os.path.join(root, "test") + assert os.path.exists(git_dir) + + # this removes the prefix (root) from the path (unmanaged_repo) + unmanaged_repo_name = os.path.relpath(unmanaged_repo, root) + regex = f".*unmanaged.*{unmanaged_repo_name}" + assert any([re.match(regex, l) for l in cmd.stderr.lower().split("\n")]) + + +def test_repos_sync_root_is_file(): + with tempfile.NamedTemporaryFile() as target: + with tempfile.NamedTemporaryFile() as config: + with open(config.name, "w") as f: + f.write( + f""" + [[trees]] + root = "{target.name}" + + [[trees.repos]] + name = "test" + """ + ) + + cmd = grm(["repos", "sync", "--config", config.name]) + assert cmd.returncode != 0 + assert len(cmd.stdout) == 0 + assert "not a directory" in cmd.stderr.lower() + + +def test_repos_sync_normal_clone(): + with tempfile.TemporaryDirectory() as target: + with TempGitFileRemote() as (remote1, remote1_head_commit_sha): + with TempGitFileRemote() as (remote2, remote2_head_commit_sha): + with tempfile.NamedTemporaryFile() as config: + with open(config.name, "w") as f: + f.write( + f""" + [[trees]] + root = "{target}" + + [[trees.repos]] + name = "test" + + [[trees.repos.remotes]] + name = "origin" + url = "file://{remote1}" + type = "file" + + [[trees.repos.remotes]] + name = "origin2" + url = "file://{remote2}" + type = "file" + """ + ) + + cmd = grm(["repos", "sync", "--config", config.name]) + assert cmd.returncode == 0 + + git_dir = os.path.join(target, "test") + assert os.path.exists(git_dir) + with git.Repo(git_dir) as repo: + assert not repo.bare + assert not repo.is_dirty() + assert set([str(r) for r in repo.remotes]) == { + "origin", + "origin2", + } + assert str(repo.active_branch) == "master" + assert str(repo.head.commit) == remote1_head_commit_sha + + assert len(repo.remotes) == 2 + urls = list(repo.remote("origin").urls) + assert len(urls) == 1 + assert urls[0] == f"file://{remote1}" + + urls = list(repo.remote("origin2").urls) + assert len(urls) == 1 + assert urls[0] == f"file://{remote2}" + + +def test_repos_sync_normal_init(): + with tempfile.TemporaryDirectory() as target: + with tempfile.NamedTemporaryFile() as config: + with open(config.name, "w") as f: + f.write( + f""" + [[trees]] + root = "{target}" + + [[trees.repos]] + name = "test" + """ + ) + + cmd = grm(["repos", "sync", "--config", config.name]) + assert cmd.returncode == 0 + + git_dir = os.path.join(target, "test") + assert os.path.exists(git_dir) + with git.Repo(git_dir) as repo: + assert not repo.bare + assert not repo.is_dirty() + # as there are no commits yet, HEAD does not point to anything + # valid + assert not repo.head.is_valid() + + +def test_repos_sync_normal_add_remote(): + with tempfile.TemporaryDirectory() as target: + with TempGitFileRemote() as (remote1, remote1_head_commit_sha): + with TempGitFileRemote() as (remote2, remote2_head_commit_sha): + with tempfile.NamedTemporaryFile() as config: + with open(config.name, "w") as f: + f.write( + f""" + [[trees]] + root = "{target}" + + [[trees.repos]] + name = "test" + + [[trees.repos.remotes]] + name = "origin" + url = "file://{remote1}" + type = "file" + """ + ) + + cmd = grm(["repos", "sync", "--config", config.name]) + assert cmd.returncode == 0 + + git_dir = os.path.join(target, "test") + + assert os.path.exists(git_dir) + with git.Repo(git_dir) as repo: + assert not repo.bare + assert not repo.is_dirty() + assert set([str(r) for r in repo.remotes]) == {"origin"} + assert str(repo.active_branch) == "master" + assert str(repo.head.commit) == remote1_head_commit_sha + + with open(config.name, "w") as f: + f.write( + f""" + [[trees]] + root = "{target}" + + [[trees.repos]] + name = "test" + + [[trees.repos.remotes]] + name = "origin" + url = "file://{remote1}" + type = "file" + + [[trees.repos.remotes]] + name = "origin2" + url = "file://{remote2}" + type = "file" + """ + ) + + cmd = grm(["repos", "sync", "--config", config.name]) + assert cmd.returncode == 0 + with git.Repo(git_dir) as repo: + assert set([str(r) for r in repo.remotes]) == { + "origin", + "origin2", + } + + urls = list(repo.remote("origin").urls) + assert len(urls) == 1 + assert urls[0] == f"file://{remote1}" + + urls = list(repo.remote("origin2").urls) + assert len(urls) == 1 + assert urls[0] == f"file://{remote2}" + + +def test_repos_sync_normal_remove_remote(): + with tempfile.TemporaryDirectory() as target: + with TempGitFileRemote() as (remote1, remote1_head_commit_sha): + with TempGitFileRemote() as (remote2, remote2_head_commit_sha): + with tempfile.NamedTemporaryFile() as config: + with open(config.name, "w") as f: + f.write( + f""" + [[trees]] + root = "{target}" + + [[trees.repos]] + name = "test" + + [[trees.repos.remotes]] + name = "origin" + url = "file://{remote1}" + type = "file" + + [[trees.repos.remotes]] + name = "origin2" + url = "file://{remote2}" + type = "file" + """ + ) + + cmd = grm(["repos", "sync", "--config", config.name]) + assert cmd.returncode == 0 + + git_dir = os.path.join(target, "test") + + assert os.path.exists(git_dir) + with git.Repo(git_dir) as repo: + assert not repo.bare + assert not repo.is_dirty() + assert set([str(r) for r in repo.remotes]) == { + "origin", + "origin2", + } + assert str(repo.active_branch) == "master" + assert str(repo.head.commit) == remote1_head_commit_sha + + with open(config.name, "w") as f: + f.write( + f""" + [[trees]] + root = "{target}" + + [[trees.repos]] + name = "test" + + [[trees.repos.remotes]] + name = "origin2" + url = "file://{remote2}" + type = "file" + """ + ) + + cmd = grm(["repos", "sync", "--config", config.name]) + assert cmd.returncode == 0 + shell(f"cd {git_dir} && git remote -v") + with git.Repo(git_dir) as repo: + """ + There is some bug(?) in GitPython. It does not properly + detect removed remotes. It will still report the old + remove in repo.remotes. + + So instead, we make sure that we get an Exception when + we try to access the old remove via repo.remote(). + + Note that repo.remote() checks the actual repo lazily. + Even `exists()` seems to just check against repo.remotes + and will return True even if the remote is not actually + configured. So we have to force GitPython to hit the filesystem. + calling Remotes.urls does. But it returns an iterator + that first has to be unwrapped via list(). Only THEN + do we actually get an exception of the remotes does not + exist. + """ + with pytest.raises(git.exc.GitCommandError): + list(repo.remote("origin").urls) + + urls = list(repo.remote("origin2").urls) + assert len(urls) == 1 + assert urls[0] == f"file://{remote2}" + + +def test_repos_sync_normal_change_remote_url(): + with tempfile.TemporaryDirectory() as target: + with TempGitFileRemote() as (remote1, remote1_head_commit_sha): + with TempGitFileRemote() as (remote2, remote2_head_commit_sha): + with tempfile.NamedTemporaryFile() as config: + with open(config.name, "w") as f: + f.write( + f""" + [[trees]] + root = "{target}" + + [[trees.repos]] + name = "test" + + [[trees.repos.remotes]] + name = "origin" + url = "file://{remote1}" + type = "file" + """ + ) + + cmd = grm(["repos", "sync", "--config", config.name]) + assert cmd.returncode == 0 + + git_dir = os.path.join(target, "test") + + assert os.path.exists(git_dir) + with git.Repo(git_dir) as repo: + assert not repo.bare + assert not repo.is_dirty() + assert set([str(r) for r in repo.remotes]) == {"origin"} + assert str(repo.active_branch) == "master" + assert str(repo.head.commit) == remote1_head_commit_sha + + with open(config.name, "w") as f: + f.write( + f""" + [[trees]] + root = "{target}" + + [[trees.repos]] + name = "test" + + [[trees.repos.remotes]] + name = "origin" + url = "file://{remote2}" + type = "file" + """ + ) + + cmd = grm(["repos", "sync", "--config", config.name]) + assert cmd.returncode == 0 + with git.Repo(git_dir) as repo: + assert set([str(r) for r in repo.remotes]) == {"origin"} + + urls = list(repo.remote("origin").urls) + assert len(urls) == 1 + assert urls[0] == f"file://{remote2}" + + +def test_repos_sync_normal_change_remote_name(): + with tempfile.TemporaryDirectory() as target: + with TempGitFileRemote() as (remote1, remote1_head_commit_sha): + with TempGitFileRemote() as (remote2, remote2_head_commit_sha): + with tempfile.NamedTemporaryFile() as config: + with open(config.name, "w") as f: + f.write( + f""" + [[trees]] + root = "{target}" + + [[trees.repos]] + name = "test" + + [[trees.repos.remotes]] + name = "origin" + url = "file://{remote1}" + type = "file" + """ + ) + + cmd = grm(["repos", "sync", "--config", config.name]) + assert cmd.returncode == 0 + + git_dir = os.path.join(target, "test") + + assert os.path.exists(git_dir) + with git.Repo(git_dir) as repo: + assert not repo.bare + assert not repo.is_dirty() + assert set([str(r) for r in repo.remotes]) == {"origin"} + assert str(repo.active_branch) == "master" + assert str(repo.head.commit) == remote1_head_commit_sha + + with open(config.name, "w") as f: + f.write( + f""" + [[trees]] + root = "{target}" + + [[trees.repos]] + name = "test" + + [[trees.repos.remotes]] + name = "origin2" + url = "file://{remote1}" + type = "file" + """ + ) + + cmd = grm(["repos", "sync", "--config", config.name]) + assert cmd.returncode == 0 + with git.Repo(git_dir) as repo: + # See the note in `test_repos_sync_normal_remove_remote()` + # about repo.remotes + with pytest.raises(git.exc.GitCommandError): + list(repo.remote("origin").urls) + + urls = list(repo.remote("origin2").urls) + assert len(urls) == 1 + assert urls[0] == f"file://{remote1}" + + +def test_repos_sync_worktree_clone(): + with tempfile.TemporaryDirectory() as target: + with TempGitFileRemote() as (remote, head_commit_sha): + with tempfile.NamedTemporaryFile() as config: + with open(config.name, "w") as f: + f.write( + f""" + [[trees]] + root = "{target}" + + [[trees.repos]] + name = "test" + worktree_setup = true + + [[trees.repos.remotes]] + name = "origin" + url = "file://{remote}" + type = "file" + """ + ) + + cmd = grm(["repos", "sync", "--config", config.name]) + assert cmd.returncode == 0 + + worktree_dir = f"{target}/test" + assert os.path.exists(worktree_dir) + + assert set(os.listdir(worktree_dir)) == {".git-main-working-tree"} + + with git.Repo( + os.path.join(worktree_dir, ".git-main-working-tree") + ) as repo: + assert repo.bare + assert set([str(r) for r in repo.remotes]) == {"origin"} + assert str(repo.active_branch) == "master" + assert str(repo.head.commit) == head_commit_sha + + +def test_repos_sync_worktree_init(): + with tempfile.TemporaryDirectory() as target: + with tempfile.NamedTemporaryFile() as config: + with open(config.name, "w") as f: + f.write( + f""" + [[trees]] + root = "{target}" + + [[trees.repos]] + name = "test" + worktree_setup = true + """ + ) + + cmd = grm(["repos", "sync", "--config", config.name]) + assert cmd.returncode == 0 + + worktree_dir = f"{target}/test" + assert os.path.exists(worktree_dir) + + assert set(os.listdir(worktree_dir)) == {".git-main-working-tree"} + with git.Repo(os.path.join(worktree_dir, ".git-main-working-tree")) as repo: + assert repo.bare + # as there are no commits yet, HEAD does not point to anything + # valid + assert not repo.head.is_valid() + + +def test_repos_sync_invalid_toml(): + with tempfile.NamedTemporaryFile() as config: + with open(config.name, "w") as f: + f.write( + f""" + [[trees]] + root = invalid as there are no quotes ;) + """ + ) + cmd = grm(["repos", "sync", "--config", config.name]) + assert cmd.returncode != 0 + + +def test_repos_sync_unchanged(): + with tempfile.TemporaryDirectory() as target: + with TempGitFileRemote() as (remote1, remote1_head_commit_sha): + with TempGitFileRemote() as (remote2, remote2_head_commit_sha): + with tempfile.NamedTemporaryFile() as config: + with open(config.name, "w") as f: + f.write( + f""" + [[trees]] + root = "{target}" + + [[trees.repos]] + name = "test" + + [[trees.repos.remotes]] + name = "origin" + url = "file://{remote1}" + type = "file" + + [[trees.repos.remotes]] + name = "origin2" + url = "file://{remote2}" + type = "file" + """ + ) + + cmd = grm(["repos", "sync", "--config", config.name]) + assert cmd.returncode == 0 + + before = checksum_directory(target) + cmd = grm(["repos", "sync", "--config", config.name]) + after = checksum_directory(target) + assert cmd.returncode == 0 + + assert before == after + + +def test_repos_sync_normal_change_to_worktree(): + with tempfile.TemporaryDirectory() as target: + with TempGitFileRemote() as (remote1, remote1_head_commit_sha): + with TempGitFileRemote() as (remote2, remote2_head_commit_sha): + with tempfile.NamedTemporaryFile() as config: + with open(config.name, "w") as f: + f.write( + f""" + [[trees]] + root = "{target}" + + [[trees.repos]] + name = "test" + + [[trees.repos.remotes]] + name = "origin" + url = "file://{remote1}" + type = "file" + """ + ) + + cmd = grm(["repos", "sync", "--config", config.name]) + assert cmd.returncode == 0 + + git_dir = os.path.join(target, "test") + + with open(config.name, "w") as f: + f.write( + f""" + [[trees]] + root = "{target}" + + [[trees.repos]] + name = "test" + worktree_setup = true + + [[trees.repos.remotes]] + name = "origin" + url = "file://{remote1}" + type = "file" + """ + ) + + cmd = grm(["repos", "sync", "--config", config.name]) + assert cmd.returncode != 0 + assert "already exists" in cmd.stderr + assert "not using a worktree setup" in cmd.stderr + + +def test_repos_sync_worktree_change_to_normal(): + with tempfile.TemporaryDirectory() as target: + with TempGitFileRemote() as (remote1, remote1_head_commit_sha): + with TempGitFileRemote() as (remote2, remote2_head_commit_sha): + with tempfile.NamedTemporaryFile() as config: + with open(config.name, "w") as f: + f.write( + f""" + [[trees]] + root = "{target}" + + [[trees.repos]] + name = "test" + worktree_setup = true + + [[trees.repos.remotes]] + name = "origin" + url = "file://{remote1}" + type = "file" + """ + ) + + cmd = grm(["repos", "sync", "--config", config.name]) + assert cmd.returncode == 0 + + git_dir = os.path.join(target, "test") + + with open(config.name, "w") as f: + f.write( + f""" + [[trees]] + root = "{target}" + + [[trees.repos]] + name = "test" + + [[trees.repos.remotes]] + name = "origin" + url = "file://{remote1}" + type = "file" + """ + ) + + cmd = grm(["repos", "sync", "--config", config.name]) + assert cmd.returncode != 0 + assert "already exists" in cmd.stderr + assert "using a worktree setup" in cmd.stderr diff --git a/e2e_tests/test_worktree_clean.py b/e2e_tests/test_worktree_clean.py new file mode 100644 index 0000000..79cc00f --- /dev/null +++ b/e2e_tests/test_worktree_clean.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 + +from helpers import * + + +def test_worktree_clean(): + with TempGitRepositoryWorktree() as base_dir: + cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) + assert cmd.returncode == 0 + assert "test" in os.listdir(base_dir) + + cmd = grm(["wt", "clean"], cwd=base_dir) + assert cmd.returncode == 0 + assert "test" not in os.listdir(base_dir) + + +def test_worktree_clean_refusal_no_tracking_branch(): + with TempGitRepositoryWorktree() as base_dir: + before = checksum_directory(base_dir) + + cmd = grm(["wt", "add", "test"], cwd=base_dir) + assert cmd.returncode == 0 + + before = checksum_directory(f"{base_dir}/test") + cmd = grm(["wt", "clean"], cwd=base_dir) + assert cmd.returncode == 0 + assert "test" in os.listdir(base_dir) + + after = checksum_directory(f"{base_dir}/test") + assert before == after + + +def test_worktree_clean_refusal_uncommited_changes_new_file(): + with TempGitRepositoryWorktree() as base_dir: + before = checksum_directory(base_dir) + + cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) + assert cmd.returncode == 0 + + shell(f"cd {base_dir}/test && touch changed_file") + + before = checksum_directory(f"{base_dir}/test") + cmd = grm(["wt", "clean"], cwd=base_dir) + assert cmd.returncode == 0 + assert "test" in os.listdir(base_dir) + + after = checksum_directory(f"{base_dir}/test") + assert before == after + + +def test_worktree_clean_refusal_uncommited_changes_changed_file(): + with TempGitRepositoryWorktree() as base_dir: + before = checksum_directory(base_dir) + + cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) + assert cmd.returncode == 0 + + shell(f"cd {base_dir}/test && git ls-files | shuf | head | xargs rm -rf") + + before = checksum_directory(f"{base_dir}/test") + cmd = grm(["wt", "clean"], cwd=base_dir) + assert cmd.returncode == 0 + assert "test" in os.listdir(base_dir) + + after = checksum_directory(f"{base_dir}/test") + assert before == after + + +def test_worktree_clean_refusal_uncommited_changes_cleand_file(): + with TempGitRepositoryWorktree() as base_dir: + before = checksum_directory(base_dir) + + cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) + assert cmd.returncode == 0 + + shell( + f"cd {base_dir}/test && git ls-files | shuf | head | while read f ; do echo $RANDOM > $f ; done" + ) + + before = checksum_directory(f"{base_dir}/test") + cmd = grm(["wt", "clean"], cwd=base_dir) + assert cmd.returncode == 0 + assert "test" in os.listdir(base_dir) + + after = checksum_directory(f"{base_dir}/test") + assert before == after + + +def test_worktree_clean_refusal_commited_changes(): + with TempGitRepositoryWorktree() as base_dir: + before = checksum_directory(base_dir) + + cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) + assert cmd.returncode == 0 + + shell( + f'cd {base_dir}/test && touch changed_file && git add changed_file && git commit -m "commitmsg"' + ) + + before = checksum_directory(f"{base_dir}/test") + cmd = grm(["wt", "clean"], cwd=base_dir) + assert cmd.returncode == 0 + assert "test" in os.listdir(base_dir) + + after = checksum_directory(f"{base_dir}/test") + assert before == after + + +def test_worktree_clean_refusal_tracking_branch_mismatch(): + with TempGitRepositoryWorktree() as base_dir: + before = checksum_directory(base_dir) + + cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) + assert cmd.returncode == 0 + + shell( + f"cd {base_dir}/test && git push origin test && git reset --hard origin/test^" + ) + + before = checksum_directory(f"{base_dir}/test") + cmd = grm(["wt", "clean"], cwd=base_dir) + assert cmd.returncode == 0 + assert "test" in os.listdir(base_dir) + + after = checksum_directory(f"{base_dir}/test") + assert before == after + + +def test_worktree_clean_fail_from_subdir(): + with TempGitRepositoryWorktree() as base_dir: + cmd = grm(["wt", "add", "test"], cwd=base_dir) + assert cmd.returncode == 0 + + cmd = grm(["wt", "clean"], cwd=f"{base_dir}/test") + assert cmd.returncode != 0 + assert len(cmd.stdout) == 0 + assert len(cmd.stderr) != 0 + + +def test_worktree_clean_non_worktree(): + with TempGitRepository() as git_dir: + cmd = grm(["wt", "clean"], cwd=git_dir) + assert cmd.returncode != 0 + assert len(cmd.stdout) == 0 + assert len(cmd.stderr) != 0 + + +def test_worktree_clean_non_git(): + with NonGitDir() as base_dir: + cmd = grm(["wt", "clean"], cwd=base_dir) + assert cmd.returncode != 0 + assert len(cmd.stdout) == 0 + assert len(cmd.stderr) != 0 diff --git a/e2e_tests/test_worktree_conversion.py b/e2e_tests/test_worktree_conversion.py new file mode 100644 index 0000000..47bb16b --- /dev/null +++ b/e2e_tests/test_worktree_conversion.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 + +import tempfile + +from helpers import * + + +def test_convert(): + with TempGitRepository() as git_dir: + cmd = grm(["wt", "convert"], cwd=git_dir) + assert cmd.returncode == 0 + + files = os.listdir(git_dir) + assert len(files) == 1 + assert files[0] == ".git-main-working-tree" + + cmd = grm(["wt", "add", "test"], cwd=git_dir) + assert cmd.returncode == 0 + + files = os.listdir(git_dir) + assert len(files) == 2 + assert set(files) == {".git-main-working-tree", "test"} + + +def test_convert_already_worktree(): + with TempGitRepositoryWorktree() as git_dir: + before = checksum_directory(git_dir) + + cmd = grm(["wt", "convert"], cwd=git_dir) + assert cmd.returncode != 0 + + after = checksum_directory(git_dir) + assert before == after + + +def test_convert_non_git(): + with NonGitDir() as dir: + before = checksum_directory(dir) + + cmd = grm(["wt", "convert"], cwd=dir) + assert cmd.returncode != 0 + + after = checksum_directory(dir) + assert before == after + + +def test_convert_empty(): + with EmptyDir() as dir: + before = checksum_directory(dir) + + cmd = grm(["wt", "convert"], cwd=dir) + assert cmd.returncode != 0 + + after = checksum_directory(dir) + assert before == after diff --git a/e2e_tests/test_worktree_status.py b/e2e_tests/test_worktree_status.py new file mode 100644 index 0000000..86a92d8 --- /dev/null +++ b/e2e_tests/test_worktree_status.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 + +from helpers import * + + +def test_worktree_status(): + with TempGitRepositoryWorktree() as base_dir: + cmd = grm(["wt", "add", "test"], cwd=base_dir) + assert cmd.returncode == 0 + + cmd = grm(["wt", "status"], cwd=base_dir) + assert cmd.returncode == 0 + assert len(cmd.stderr) == 0 + stdout = cmd.stdout.lower() + assert "test" in stdout + + +def test_worktree_status_fail_from_subdir(): + with TempGitRepositoryWorktree() as base_dir: + cmd = grm(["wt", "add", "test"], cwd=base_dir) + assert cmd.returncode == 0 + + cmd = grm(["wt", "status"], cwd=f"{base_dir}/test") + assert cmd.returncode != 0 + assert len(cmd.stdout) == 0 + assert len(cmd.stderr) != 0 + + +def test_worktree_status_non_worktree(): + with TempGitRepository() as git_dir: + cmd = grm(["wt", "status"], cwd=git_dir) + assert cmd.returncode != 0 + assert len(cmd.stdout) == 0 + assert len(cmd.stderr) != 0 + + +def test_worktree_status_non_git(): + with NonGitDir() as base_dir: + cmd = grm(["wt", "status"], cwd=base_dir) + assert cmd.returncode != 0 + assert len(cmd.stdout) == 0 + assert len(cmd.stderr) != 0 diff --git a/e2e_tests/test_worktrees.py b/e2e_tests/test_worktrees.py new file mode 100644 index 0000000..6ddf641 --- /dev/null +++ b/e2e_tests/test_worktrees.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 + +from helpers import * + +import git + + +def test_worktree_add_simple(): + with TempGitRepositoryWorktree() as base_dir: + before = checksum_directory(base_dir) + + cmd = grm(["wt", "add", "test"], cwd=base_dir) + assert cmd.returncode == 0 + + files = os.listdir(base_dir) + assert len(files) == 2 + assert set(files) == {".git-main-working-tree", "test"} + + repo = git.Repo(os.path.join(base_dir, "test")) + assert not repo.bare + assert not repo.is_dirty() + assert str(repo.active_branch) == "test" + assert repo.active_branch.tracking_branch() is None + + +def test_worktree_add_with_tracking(): + with TempGitRepositoryWorktree() as base_dir: + before = checksum_directory(base_dir) + + cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) + print(cmd.stderr) + assert cmd.returncode == 0 + + files = os.listdir(base_dir) + assert len(files) == 2 + assert set(files) == {".git-main-working-tree", "test"} + + repo = git.Repo(os.path.join(base_dir, "test")) + assert not repo.bare + assert not repo.is_dirty() + assert str(repo.active_branch) == "test" + assert str(repo.active_branch.tracking_branch()) == "origin/test" + + +def test_worktree_delete(): + with TempGitRepositoryWorktree() as base_dir: + cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) + assert cmd.returncode == 0 + assert "test" in os.listdir(base_dir) + + cmd = grm(["wt", "delete", "test"], cwd=base_dir) + assert cmd.returncode == 0 + assert "test" not in os.listdir(base_dir) + + cmd = grm(["wt", "add", "check"], cwd=base_dir) + assert cmd.returncode == 0 + repo = git.Repo(os.path.join(base_dir, ".git-main-working-tree")) + print(repo.branches) + assert "test" not in [str(b) for b in repo.branches] + + +def test_worktree_delete_refusal_no_tracking_branch(): + with TempGitRepositoryWorktree() as base_dir: + before = checksum_directory(base_dir) + + cmd = grm(["wt", "add", "test"], cwd=base_dir) + assert cmd.returncode == 0 + + before = checksum_directory(f"{base_dir}/test") + cmd = grm(["wt", "delete", "test"], cwd=base_dir) + assert cmd.returncode != 0 + stderr = cmd.stderr.lower() + assert "refuse" in stderr or "refusing" in stderr + assert "test" in os.listdir(base_dir) + + after = checksum_directory(f"{base_dir}/test") + assert before == after + + +def test_worktree_delete_refusal_uncommited_changes_new_file(): + with TempGitRepositoryWorktree() as base_dir: + before = checksum_directory(base_dir) + + cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) + assert cmd.returncode == 0 + + shell(f"cd {base_dir}/test && touch changed_file") + + before = checksum_directory(f"{base_dir}/test") + cmd = grm(["wt", "delete", "test"], cwd=base_dir) + assert cmd.returncode != 0 + stderr = cmd.stderr.lower() + assert "refuse" in stderr or "refusing" in stderr + assert "test" in os.listdir(base_dir) + + after = checksum_directory(f"{base_dir}/test") + assert before == after + + +def test_worktree_delete_refusal_uncommited_changes_changed_file(): + with TempGitRepositoryWorktree() as base_dir: + before = checksum_directory(base_dir) + + cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) + assert cmd.returncode == 0 + + shell(f"cd {base_dir}/test && git ls-files | shuf | head | xargs rm -rf") + + before = checksum_directory(f"{base_dir}/test") + cmd = grm(["wt", "delete", "test"], cwd=base_dir) + assert cmd.returncode != 0 + stderr = cmd.stderr.lower() + assert "refuse" in stderr or "refusing" in stderr + assert "test" in os.listdir(base_dir) + + after = checksum_directory(f"{base_dir}/test") + assert before == after + + +def test_worktree_delete_refusal_uncommited_changes_deleted_file(): + with TempGitRepositoryWorktree() as base_dir: + before = checksum_directory(base_dir) + + cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) + assert cmd.returncode == 0 + + shell( + f"cd {base_dir}/test && git ls-files | shuf | head | while read f ; do echo $RANDOM > $f ; done" + ) + + before = checksum_directory(f"{base_dir}/test") + cmd = grm(["wt", "delete", "test"], cwd=base_dir) + assert cmd.returncode != 0 + stderr = cmd.stderr.lower() + assert "refuse" in stderr or "refusing" in stderr + assert "test" in os.listdir(base_dir) + + after = checksum_directory(f"{base_dir}/test") + assert before == after + + +def test_worktree_delete_refusal_commited_changes(): + with TempGitRepositoryWorktree() as base_dir: + before = checksum_directory(base_dir) + + cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) + assert cmd.returncode == 0 + + shell( + f'cd {base_dir}/test && touch changed_file && git add changed_file && git commit -m "commitmsg"' + ) + + before = checksum_directory(f"{base_dir}/test") + cmd = grm(["wt", "delete", "test"], cwd=base_dir) + assert cmd.returncode != 0 + stderr = cmd.stderr.lower() + assert "refuse" in stderr or "refusing" in stderr + assert "test" in os.listdir(base_dir) + + after = checksum_directory(f"{base_dir}/test") + assert before == after + + +def test_worktree_delete_refusal_tracking_branch_mismatch(): + with TempGitRepositoryWorktree() as base_dir: + before = checksum_directory(base_dir) + + cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) + assert cmd.returncode == 0 + + shell( + f"cd {base_dir}/test && git push origin test && git reset --hard origin/test^" + ) + + before = checksum_directory(f"{base_dir}/test") + cmd = grm(["wt", "delete", "test"], cwd=base_dir) + assert cmd.returncode != 0 + stderr = cmd.stderr.lower() + assert "refuse" in stderr or "refusing" in stderr + assert "test" in os.listdir(base_dir) + + after = checksum_directory(f"{base_dir}/test") + assert before == after + + +def test_worktree_delete_force_refusal(): + with TempGitRepositoryWorktree() as base_dir: + before = checksum_directory(base_dir) + + cmd = grm(["wt", "add", "test"], cwd=base_dir) + assert cmd.returncode == 0 + + cmd = grm(["wt", "delete", "test", "--force"], cwd=base_dir) + assert cmd.returncode == 0 + assert "test" not in os.listdir(base_dir) + + +def test_worktree_add_delete_add(): + with TempGitRepositoryWorktree() as base_dir: + cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) + assert cmd.returncode == 0 + assert "test" in os.listdir(base_dir) + + cmd = grm(["wt", "delete", "test"], cwd=base_dir) + assert cmd.returncode == 0 + assert "test" not in os.listdir(base_dir) + + cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) + assert cmd.returncode == 0 + assert "test" in os.listdir(base_dir)