From 47841dadfb8696cac7900dc38c09d6ce0eaf9230 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Fri, 26 Nov 2021 17:22:09 +0100 Subject: [PATCH 01/14] Release v0.3 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 03d6006..e2c7018 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -188,7 +188,7 @@ dependencies = [ [[package]] name = "git-repo-manager" -version = "0.1.0" +version = "0.3.0" dependencies = [ "clap", "comfy-table", diff --git a/Cargo.toml b/Cargo.toml index a395a9c..0ff3f8d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "git-repo-manager" -version = "0.2.0" +version = "0.3.0" edition = "2021" authors = [ "Hannes Körber ", From 09c67d49085a436e61bf3fc2fcfa1bb66683f46c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Wed, 24 Nov 2021 21:15:11 +0100 Subject: [PATCH 02/14] Remove wrong error message about remote branch --- src/lib.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index efa5edc..d3a5cb2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -949,10 +949,6 @@ pub fn run() { .set_upstream(Some(&upstream_branch_name)) .unwrap(); } else { - print_error(&format!( - "Remote branch {} not found", - &upstream_branch_name - )); let split_at = upstream_branch_name.find('/').unwrap_or(0); if split_at == 0 || split_at >= upstream_branch_name.len() - 1 { print_error("Tracking branch needs to match the pattern /"); From 0973ae36b86b88500db6bf010ecbea1dcbd3d71d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Sun, 28 Nov 2021 16:15:42 +0100 Subject: [PATCH 03/14] Properly report push errors --- src/lib.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index d3a5cb2..be54938 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -993,7 +993,10 @@ pub fn run() { ); remote .push(&[push_refspec], Some(&mut push_options)) - .unwrap(); + .unwrap_or_else(|error| { + print_error(&format!("Pushing to {} ({}) failed: {}", remote_name, remote.url().unwrap(), error)); + process::exit(1); + }); target_branch .set_upstream(Some(&upstream_branch_name)) From 48f3bc01997d12e7c2bd116c9bf658109248b2ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Sun, 28 Nov 2021 16:15:50 +0100 Subject: [PATCH 04/14] Support file remotes --- src/repo.rs | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/repo.rs b/src/repo.rs index 75f30e8..a917f4a 100644 --- a/src/repo.rs +++ b/src/repo.rs @@ -10,6 +10,7 @@ use crate::output::*; pub enum RemoteType { Ssh, Https, + File, } #[derive(Debug, PartialEq)] @@ -127,6 +128,14 @@ mod tests { ); } + #[test] + fn check_file_remote() { + assert_eq!( + detect_remote_type("file:///somedir"), + Some(RemoteType::File) + ); + } + #[test] fn check_invalid_remotes() { assert_eq!(detect_remote_type("https//example.com"), None); @@ -147,12 +156,6 @@ mod tests { fn check_unsupported_protocol_git() { detect_remote_type("git://example.com"); } - - #[test] - #[should_panic] - fn check_unsupported_protocol_file() { - detect_remote_type("file:///"); - } } pub fn detect_remote_type(remote_url: &str) -> Option { @@ -166,15 +169,15 @@ pub fn detect_remote_type(remote_url: &str) -> Option { if remote_url.starts_with("https://") { return Some(RemoteType::Https); } + if remote_url.starts_with("file://") { + return Some(RemoteType::File); + } if remote_url.starts_with("http://") { unimplemented!("Remotes using HTTP protocol are not supported"); } if remote_url.starts_with("git://") { unimplemented!("Remotes using git protocol are not supported"); } - if remote_url.starts_with("file://") || remote_url.starts_with('/') { - unimplemented!("Remotes using local protocol are not supported"); - } None } @@ -254,7 +257,7 @@ pub fn clone_repo( &remote.url )); match remote.remote_type { - RemoteType::Https => { + RemoteType::Https | RemoteType::File => { let mut builder = git2::build::RepoBuilder::new(); builder.bare(is_worktree); builder.clone(&remote.url, &clone_target)?; From e43d4bf3cdd5119ac7d97d16673e675b6cc2fdcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Sun, 28 Nov 2021 16:14:54 +0100 Subject: [PATCH 05/14] Split unit and integ tests in Justfile --- Justfile | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Justfile b/Justfile index d090239..9ee01f6 100644 --- a/Justfile +++ b/Justfile @@ -12,9 +12,14 @@ release: install: cargo install --path . -test: +test: test-unit test-integration + +test-unit: cargo test --lib --bins +test-integration: + cargo test --test "*" + update-dependencies: @cd ./depcheck \ && python3 -m venv ./venv \ From f5f8dfa188f56d63ad635de17ac3ba2154806b41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Mon, 29 Nov 2021 00:24:27 +0100 Subject: [PATCH 06/14] Better error message when config not found --- src/config.rs | 8 ++++++-- src/lib.rs | 2 ++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/config.rs b/src/config.rs index 69d8442..7a0caec 100644 --- a/src/config.rs +++ b/src/config.rs @@ -21,8 +21,12 @@ pub fn read_config(path: &str) -> Result { Err(e) => { return Err(format!( "Error reading configuration file \"{}\": {}", - path, e - )) + path, + match e.kind() { + std::io::ErrorKind::NotFound => String::from("not found"), + _ => e.to_string(), + } + )); } }; diff --git a/src/lib.rs b/src/lib.rs index be54938..253776c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,5 @@ +#![feature(io_error_more)] + use std::fs; use std::path::{Path, PathBuf}; use std::process; From 340085abf846bd99761365e91644eb1862e8162c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Mon, 29 Nov 2021 00:28:41 +0100 Subject: [PATCH 07/14] Detect change from worktree to non-worktree during sync --- src/lib.rs | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 253776c..c22c008 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -135,15 +135,25 @@ fn sync_trees(config: Config) { ); process::exit(1); } - repo_handle = Some(open_repo(&repo_path, repo.worktree_setup).unwrap_or_else( - |error| { - print_repo_error( - &repo.name, - &format!("Opening repository failed: {}", error), - ); + repo_handle = match open_repo(&repo_path, repo.worktree_setup) { + Ok(repo) => Some(repo), + Err(error) => { + if !repo.worktree_setup { + if open_repo(&repo_path, true).is_ok() { + print_repo_error( + &repo.name, + "Repo already exists, but is using a worktree setup", + ); + } + } else { + print_repo_error( + &repo.name, + &format!("Opening repository failed: {}", error), + ); + } process::exit(1); - }, - )); + } + }; } else if matches!(&repo.remotes, None) || repo.remotes.as_ref().unwrap().is_empty() { print_repo_action( &repo.name, From 655379cd611d855badcaff794dcc2d01ecfdd3ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Mon, 29 Nov 2021 00:29:10 +0100 Subject: [PATCH 08/14] Return failures during sync --- src/lib.rs | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index c22c008..0ee483f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -115,7 +115,8 @@ fn get_default_branch(repo: &git2::Repository) -> Result { } } -fn sync_trees(config: Config) { +fn sync_trees(config: Config) -> bool { + let mut failures = false; for tree in config.trees { let repos = tree.repos.unwrap_or_default(); @@ -133,7 +134,8 @@ fn sync_trees(config: Config) { &repo.name, "Repo already exists, but is not using a worktree setup", ); - process::exit(1); + failures = true; + continue; } repo_handle = match open_repo(&repo_path, repo.worktree_setup) { Ok(repo) => Some(repo), @@ -151,7 +153,8 @@ fn sync_trees(config: Config) { &format!("Opening repository failed: {}", error), ); } - process::exit(1); + failures = true; + continue; } }; } else if matches!(&repo.remotes, None) || repo.remotes.as_ref().unwrap().is_empty() { @@ -200,6 +203,7 @@ fn sync_trees(config: Config) { &repo.name, &format!("Repository failed during getting the remotes: {}", e), ); + failures = true; continue; } } @@ -222,6 +226,7 @@ fn sync_trees(config: Config) { &repo.name, &format!("Repository failed during setting the remotes: {}", e), ); + failures = true; continue; } } else { @@ -230,6 +235,7 @@ fn sync_trees(config: Config) { Some(url) => url, None => { print_repo_error(&repo.name, &format!("Repository failed during getting of the remote URL for remote \"{}\". This is most likely caused by a non-utf8 remote name", remote.name)); + failures = true; continue; } }; @@ -240,6 +246,7 @@ fn sync_trees(config: Config) { ); if let Err(e) = repo_handle.remote_set_url(&remote.name, &remote.url) { print_repo_error(&repo.name, &format!("Repository failed during setting of the remote URL for remote \"{}\": {}", &remote.name, e)); + failures = true; continue; }; } @@ -260,6 +267,7 @@ fn sync_trees(config: Config) { ¤t_remote, e ), ); + failures = true; continue; } } @@ -277,6 +285,8 @@ fn sync_trees(config: Config) { } } } + + !failures } fn find_repos_without_details(path: &Path) -> Option> { @@ -821,7 +831,9 @@ pub fn run() { process::exit(1); } }; - sync_trees(config); + if !sync_trees(config) { + process::exit(1); + } } cmd::ReposAction::Status(args) => match &args.config { Some(config_path) => { From e2e55b8e799c031268a76cc3eb4f7670c340fb1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Mon, 29 Nov 2021 00:29:47 +0100 Subject: [PATCH 09/14] Properly handle error during repo open --- src/lib.rs | 36 ++++++++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 0ee483f..4bff4a7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -277,7 +277,15 @@ fn sync_trees(config: Config) -> bool { print_repo_success(&repo.name, "OK"); } - let current_repos = find_repos_without_details(&root_path).unwrap(); + let current_repos = match find_repos_without_details(&root_path) { + Ok(repos) => repos, + Err(error) => { + print_error(&error.to_string()); + failures = true; + continue; + } + }; + for (repo, _) in current_repos { let name = path_as_string(repo.strip_prefix(&root_path).unwrap()); if !repos.iter().any(|r| r.name == name) { @@ -289,7 +297,7 @@ fn sync_trees(config: Config) -> bool { !failures } -fn find_repos_without_details(path: &Path) -> Option> { +fn find_repos_without_details(path: &Path) -> Result, String> { let mut repos: Vec<(PathBuf, bool)> = Vec::new(); let git_dir = path.join(".git"); @@ -310,26 +318,34 @@ fn find_repos_without_details(path: &Path) -> Option> { continue; } if path.is_dir() { - if let Some(mut r) = find_repos_without_details(&path) { - repos.append(&mut r); - }; + match find_repos_without_details(&path) { + Ok(ref mut r) => repos.append(r), + Err(error) => return Err(error), + } } } Err(e) => { - print_error(&format!("Error accessing directory: {}", e)); - continue; + return Err(format!("Error accessing directory: {}", e)); } }; } } Err(e) => { - print_error(&format!("Failed to open \"{}\": {}", &path.display(), &e)); - return None; + return Err(format!( + "Failed to open \"{}\": {}", + &path.display(), + match e.kind() { + std::io::ErrorKind::NotADirectory => + String::from("directory expected, but path is not a directory"), + std::io::ErrorKind::NotFound => String::from("not found"), + _ => format!("{:?}", e.kind()), + } + )); } }; } - Some(repos) + Ok(repos) } fn get_actual_git_directory(path: &Path, is_worktree: bool) -> PathBuf { From 4e83aba672643d7017251bd50f0b1f6027737174 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Mon, 29 Nov 2021 00:32:29 +0100 Subject: [PATCH 10/14] Fix formatting of push error message --- src/lib.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index 4bff4a7..6064cb9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1034,7 +1034,12 @@ pub fn run() { remote .push(&[push_refspec], Some(&mut push_options)) .unwrap_or_else(|error| { - print_error(&format!("Pushing to {} ({}) failed: {}", remote_name, remote.url().unwrap(), error)); + print_error(&format!( + "Pushing to {} ({}) failed: {}", + remote_name, + remote.url().unwrap(), + error + )); process::exit(1); }); From f02a0fc17a87e2ee94c0724257a1db5c9673343a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Mon, 29 Nov 2021 00:32:46 +0100 Subject: [PATCH 11/14] Format cargo update script with black --- depcheck/update-cargo-dependencies.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/depcheck/update-cargo-dependencies.py b/depcheck/update-cargo-dependencies.py index 31cc166..c146432 100755 --- a/depcheck/update-cargo-dependencies.py +++ b/depcheck/update-cargo-dependencies.py @@ -21,7 +21,7 @@ else: subprocess.run( ["git", "clone", "--depth=1", "https://github.com/rust-lang/crates.io-index"], check=True, - capture_output=False, # to get some git output + capture_output=False, # to get some git output ) with open("../Cargo.toml", "r") as cargo_config: From d0b78686e2c8eedea3e0c690df3a86b0b28e690f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Mon, 29 Nov 2021 00:33:23 +0100 Subject: [PATCH 12/14] Add an E2E test suite --- Justfile | 14 +- e2e_tests/.gitignore | 2 + e2e_tests/helpers.py | 229 +++++++++ e2e_tests/requirements.txt | 12 + e2e_tests/test_basic.py | 13 + e2e_tests/test_repos_find.py | 160 ++++++ e2e_tests/test_repos_sync.py | 707 ++++++++++++++++++++++++++ e2e_tests/test_worktree_clean.py | 153 ++++++ e2e_tests/test_worktree_conversion.py | 55 ++ e2e_tests/test_worktree_status.py | 42 ++ e2e_tests/test_worktrees.py | 210 ++++++++ 11 files changed, 1596 insertions(+), 1 deletion(-) create mode 100644 e2e_tests/.gitignore create mode 100644 e2e_tests/helpers.py create mode 100644 e2e_tests/requirements.txt create mode 100644 e2e_tests/test_basic.py create mode 100644 e2e_tests/test_repos_find.py create mode 100644 e2e_tests/test_repos_sync.py create mode 100644 e2e_tests/test_worktree_clean.py create mode 100644 e2e_tests/test_worktree_conversion.py create mode 100644 e2e_tests/test_worktree_status.py create mode 100644 e2e_tests/test_worktrees.py 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) From df0b5728fc96a9bf2b50b38039e9d83e74d03afa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Mon, 29 Nov 2021 00:36:13 +0100 Subject: [PATCH 13/14] Add test to "check" Justfile target --- Justfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Justfile b/Justfile index 741c1ac..4544d28 100644 --- a/Justfile +++ b/Justfile @@ -1,4 +1,4 @@ -check: +check: test cargo check cargo fmt --check cargo clippy --no-deps From 43c47bdca600fddb9ad7b5e39bcecd1d11569e77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Mon, 29 Nov 2021 00:45:41 +0100 Subject: [PATCH 14/14] Fail with non-zero exit code on clippy warnings --- Justfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Justfile b/Justfile index 4544d28..28acf3e 100644 --- a/Justfile +++ b/Justfile @@ -1,7 +1,7 @@ check: test cargo check cargo fmt --check - cargo clippy --no-deps + cargo clippy --no-deps -- -Dwarnings lint-fix: cargo clippy --no-deps --fix