diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 87bc134..68e2173 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -47,6 +47,8 @@ jobs: - uses: actions-rust-lang/setup-rust-toolchain@v1 - uses: EmbarkStudios/cargo-deny-action@v1 + with: + rust-version: "1.84.0" audit: runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 23d0063..b56557f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## Unreleased +### Added + +- Support `[[items]] revision` property that accepts a tag, branch, or commit SHA1. + +### Changed + +- `tinty update` now keeps the items' `origin` remote URLs up-to-date. +- The item repositories are now checked out to a specific commit SHA1 in "detached HEAD" mode. + ### Fixed - Fix bug where period preceeding extension is still added to generated files even when an extension doesn't exist diff --git a/Cargo.lock b/Cargo.lock index 4fc1506..e8d0a86 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "adler2" @@ -1467,6 +1467,8 @@ dependencies = [ "clap_complete", "hex_color", "home", + "rand", + "regex", "serde", "serde_yaml", "shell-words", diff --git a/Cargo.toml b/Cargo.toml index c889c54..c6e2a3b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,3 +27,5 @@ toml = "0.8.19" url = "2.5.4" xdg = "2.5.2" home = "0.5.11" +rand = "0.8.5" +regex = "1.7" diff --git a/README.md b/README.md index fd59fa8..309422a 100644 --- a/README.md +++ b/README.md @@ -260,6 +260,7 @@ themes across different applications seamlessly. |------------------------|----------|----------|---------------------------------------------------------------|---------|--------------------------------------------| | `name` | `string` | Required | A unique name for the item being configured. | - | `name = "vim"` | | `path` | `string` | Required | The file system path or URL to the theme template repository. Paths beginning with `~/` map to home dir. | - | `path = "https://github.com/tinted-tmux"` | +| `revision` | `string` | Optional | The Git revision to use.
Accepts a branch name, a tag, or a commit SHA1 | `main` | `revision = "1.2.0"` | | `themes-dir` | `string` | Required | The directory within the repository where theme files are located. | - | `themes-dir = "colors"` | | `hook` | `string` | Optional | A command to be executed after the theme is applied. Useful for reloading configurations. `%f` template variable maps to the path of the applied theme file. | None | `hook = "source ~/.vimrc"` | | `theme-file-extension` | `string` | Optional | Define a custom theme file extension that isn't `/\.*$/`. Tinty looks for themes named `base16-uwunicorn.*` (for example), but when the theme file isn't structured that way, this option can help specify the pattern. | - | `theme-file-extension = ".module.css"` | diff --git a/src/config.rs b/src/config.rs index 1adeec8..a508986 100644 --- a/src/config.rs +++ b/src/config.rs @@ -29,11 +29,13 @@ pub struct ConfigItem { pub supported_systems: Option>, #[serde(rename = "theme-file-extension")] pub theme_file_extension: Option, + pub revision: Option, } impl fmt::Display for ConfigItem { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let hook = self.hook.clone().unwrap_or_default(); + let revision = self.revision.clone().unwrap_or_default(); let default_supported_systems = vec![SchemeSystem::default()]; let system_text = self .supported_systems @@ -52,6 +54,9 @@ impl fmt::Display for ConfigItem { if !hook.is_empty() { writeln!(f, "hook = \"{}\"", hook)?; } + if !revision.is_empty() { + writeln!(f, "revision = \"{}\"", revision)?; + } writeln!(f, "supported-systems = [{}]", system_text)?; write!(f, "themes-dir = \"{}\"", self.themes_dir) } @@ -108,6 +113,7 @@ impl Config { hook: Some(BASE16_SHELL_HOOK.to_string()), supported_systems: Some(vec![SchemeSystem::Base16]), // DEFAULT_SCHEME_SYSTEM theme_file_extension: None, + revision: None, }; // Add default `item` if no items exist diff --git a/src/constants.rs b/src/constants.rs index 77f962a..ccc79d6 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -7,3 +7,4 @@ pub const CUSTOM_SCHEMES_DIR_NAME: &str = "custom-schemes"; pub const CURRENT_SCHEME_FILE_NAME: &str = "current_scheme"; pub const DEFAULT_SCHEME_SYSTEM: &str = "base16"; pub const SCHEME_EXTENSION: &str = "yaml"; +pub const SCHEMES_REPO_REVISION: &str = "spec-0.11"; diff --git a/src/operations/install.rs b/src/operations/install.rs index b5c8c8e..a5cb872 100644 --- a/src/operations/install.rs +++ b/src/operations/install.rs @@ -1,5 +1,5 @@ use crate::config::Config; -use crate::constants::{REPO_DIR, SCHEMES_REPO_NAME, SCHEMES_REPO_URL}; +use crate::constants::{REPO_DIR, SCHEMES_REPO_NAME, SCHEMES_REPO_REVISION, SCHEMES_REPO_URL}; use crate::utils::git_clone; use anyhow::{anyhow, Result}; use std::fs::{remove_file as remove_symlink, symlink_metadata}; @@ -11,10 +11,11 @@ fn install_git_url( data_item_path: &Path, item_name: &str, item_git_url: &str, + revision: Option<&str>, is_quiet: bool, ) -> Result<()> { if !data_item_path.is_dir() { - git_clone(item_git_url, data_item_path)?; + git_clone(item_git_url, data_item_path, revision)?; if !is_quiet { println!("{} installed", item_name); @@ -86,6 +87,7 @@ pub fn install(config_path: &Path, data_path: &Path, is_quiet: bool) -> Result<( &data_item_path, item.name.as_str(), item.path.as_str(), + item.revision.as_deref(), is_quiet, )?, Err(_) => install_dir(&data_item_path, item.name.as_str(), &item_path, is_quiet)?, @@ -98,6 +100,7 @@ pub fn install(config_path: &Path, data_path: &Path, is_quiet: bool) -> Result<( &schemes_repo_path, SCHEMES_REPO_NAME, SCHEMES_REPO_URL, + Some(SCHEMES_REPO_REVISION), is_quiet, )?; diff --git a/src/operations/update.rs b/src/operations/update.rs index 0ea4852..f5e4c0e 100644 --- a/src/operations/update.rs +++ b/src/operations/update.rs @@ -1,16 +1,28 @@ -use crate::constants::{REPO_DIR, SCHEMES_REPO_NAME, SCHEMES_REPO_URL}; -use crate::utils::{git_diff, git_pull}; +use crate::constants::{REPO_DIR, SCHEMES_REPO_NAME, SCHEMES_REPO_REVISION, SCHEMES_REPO_URL}; +use crate::utils::{git_is_working_dir_clean, git_update}; use crate::{config::Config, constants::REPO_NAME}; use anyhow::{Context, Result}; use std::path::Path; -fn update_item(item_name: &str, item_url: &str, item_path: &Path, is_quiet: bool) -> Result<()> { +fn update_item( + item_name: &str, + item_url: &str, + item_path: &Path, + revision: Option<&str>, + is_quiet: bool, +) -> Result<()> { if item_path.is_dir() { - let is_diff = git_diff(item_path)?; + let is_clean = git_is_working_dir_clean(item_path)?; - if !is_diff { - git_pull(item_path) - .with_context(|| format!("Error pulling {} from {}", item_name, item_url))?; + if is_clean { + git_update(item_path, item_url, revision).with_context(|| { + format!( + "Error updating {} to {}@{}", + item_name, + item_url, + revision.unwrap_or("main") + ) + })?; if !is_quiet { println!("{} up to date", item_name); @@ -36,7 +48,13 @@ pub fn update(config_path: &Path, data_path: &Path, is_quiet: bool) -> Result<() for item in items { let item_path = hooks_path.join(&item.name); - update_item(item.name.as_str(), item.path.as_str(), &item_path, is_quiet)?; + update_item( + item.name.as_str(), + item.path.as_str(), + &item_path, + item.revision.as_deref(), + is_quiet, + )?; } let schemes_repo_path = hooks_path.join(SCHEMES_REPO_NAME); @@ -45,6 +63,7 @@ pub fn update(config_path: &Path, data_path: &Path, is_quiet: bool) -> Result<() SCHEMES_REPO_NAME, SCHEMES_REPO_URL, &schemes_repo_path, + Some(SCHEMES_REPO_REVISION), is_quiet, )?; diff --git a/src/utils.rs b/src/utils.rs index 75986dd..9b9ffee 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,9 +1,11 @@ use crate::config::{Config, ConfigItem, DEFAULT_CONFIG_SHELL}; use crate::constants::{REPO_NAME, SCHEME_EXTENSION}; -use anyhow::{anyhow, Context, Result}; +use anyhow::{anyhow, Context, Error, Result}; use home::home_dir; +use rand::Rng; +use regex::bytes::Regex; use std::fs::{self, File}; -use std::io::Write; +use std::io::{BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::str; @@ -41,7 +43,7 @@ pub fn get_shell_command_from_string(config_path: &Path, command: &str) -> Resul shell_words::split(&full_command).map_err(anyhow::Error::new) } -pub fn git_clone(repo_url: &str, target_dir: &Path) -> Result<()> { +pub fn git_clone(repo_url: &str, target_dir: &Path, revision: Option<&str>) -> Result<()> { if target_dir.exists() { return Err(anyhow!( "Error cloning {}. Target directory '{}' already exists", @@ -59,58 +61,291 @@ pub fn git_clone(repo_url: &str, target_dir: &Path) -> Result<()> { .status() .with_context(|| format!("Failed to clone repository from {}", repo_url))?; + let revision_str = revision.unwrap_or("main"); + let result = git_to_revision(target_dir, "origin", revision_str); + if let Err(e) = result { + // Cleanup! If we cannot checkout the revision, remove the directory. + fs::remove_dir_all(target_dir) + .with_context(|| format!("Failed to remove directory {}", target_dir.display()))?; + return Err(e); + } Ok(()) } -pub fn git_pull(repo_path: &Path) -> Result<()> { +pub fn git_update(repo_path: &Path, repo_url: &str, revision: Option<&str>) -> Result<()> { if !repo_path.is_dir() { return Err(anyhow!( - "Error with git pull. {} is not a directory", + "Error with updating. {} is not a directory", repo_path.display() )); } - let command = "git pull"; - let command_vec = shell_words::split(command).map_err(anyhow::Error::new)?; + // To make this operation atomic, we'll satisfy the remote & revision in this sequence: + // 1.) add the remote URL as a new temporary remote. + // 2.) check if the revision exists in the temporary remote. + // 3.) checkout the revision from temporary remote + // 4.) On success: + // 4.1) replace the origin remote URL + // 4.2) remove the temporary remote + // 5.) On error, remove temporary remote + // + // Note that this sequence works even if the directory is already on that remote & revision. + // + let tmp_remote_name = random_remote_name(); - let status = Command::new(&command_vec[0]) - .args(&command_vec[1..]) - .current_dir(repo_path) + // Create a temporary remote + safe_command( + format!("git remote add \"{}\" \"{}\"", tmp_remote_name, repo_url), + repo_path, + )? + .current_dir(repo_path) + .stdout(Stdio::null()) + .status() + .with_context(|| { + format!( + "Error with adding {} as a remote named {} in {}", + repo_url, + tmp_remote_name, + repo_path.display() + ) + })?; + + let revision_str = revision.unwrap_or("main"); + let res = git_to_revision(repo_path, &tmp_remote_name, revision_str); + + if let Err(e) = res { + // Failed to switch to the desired revision. Cleanup! + safe_command(format!("git remote rm \"{}\"", &tmp_remote_name), repo_path)? + .stdout(Stdio::null()) + .status() + .with_context(|| { + format!( + "Failed to remove temporary remote {} in {}", + tmp_remote_name, + repo_path.display() + ) + })?; + return Err(e); + } + + safe_command( + format!("git remote set-url origin \"{}\"", repo_url), + repo_path, + )? + .stdout(Stdio::null()) + .status() + .with_context(|| { + format!( + "Failed to set origin remote to {} in {}", + repo_url, + repo_path.display() + ) + })?; + safe_command(format!("git remote rm \"{}\"", tmp_remote_name), repo_path)? .stdout(Stdio::null()) .status() - .with_context(|| format!("Failed to execute process in {}", repo_path.display()))?; + .with_context(|| { + format!( + "Failed to remove temporary remote {} in {}", + tmp_remote_name, + repo_path.display() + ) + })?; + return Ok(()); +} - if status.success() { - Ok(()) - } else { - Err(anyhow!("Error wth git pull in {}", repo_path.display())) - } +fn random_remote_name() -> String { + let mut rng = rand::thread_rng(); + let random_number: u32 = rng.gen(); + format!("tinty-remote-{}", random_number) } -pub fn git_diff(target_dir: &Path) -> Result { - let command = "git status --porcelain"; - let command_vec = shell_words::split(command).map_err(anyhow::Error::new)?; - let output = Command::new(&command_vec[0]) - .args(&command_vec[1..]) - .current_dir(target_dir) - .output() - .with_context(|| format!("Failed to execute process in {}", target_dir.display()))?; - let stdout = str::from_utf8(&output.stdout).expect("Not valid UTF-8"); +// Resolvees the SHA1 of revision at remote_name. +// revision can be a tag, a branch, or a commit SHA1. +fn git_resolve_revision(repo_path: &Path, remote_name: &str, revision: &str) -> Result { + // 1.) Check if its a tag. + let expected_tag_ref = format!("refs/tags/{}", revision); + let mut command = safe_command( + format!( + "git ls-remote --quiet --tags \"{}\" \"{}\"", + remote_name, expected_tag_ref + ), + repo_path, + )?; + let mut child = command + .stderr(Stdio::null()) + .stdout(Stdio::piped()) + .spawn() + .with_context(|| format!("Failed to spawn"))?; - // If there is no output, then there is no diff - if stdout.is_empty() { - return Ok(false); + let stdout = child.stdout.take().expect("failed to capture stdout"); + let reader = BufReader::new(stdout); + + if let Some(parts) = reader + .lines() + .filter_map(|line| line.ok()) + .map(|line| line.split("\t").map(String::from).collect::>()) + .filter(|parts| parts.len() == 2) + .find(|parts| parts[1] == expected_tag_ref) + { + // we found a tag that matches + child.kill()?; // Abort the child process. + child.wait()?; // Cleanup + return Ok(parts[0].to_string()); // Return early. } - // Iterate over the lines and check for changes that should be considered a diff - // Don't consider untracked files a diff - let has_diff = stdout.lines().any(|line| { - let status_code = &line[..2]; - // Status codes: M = modified, A = added, ?? = untracked - status_code != "??" - }); + child + .wait() + .with_context(|| format!("Failed to list remote tags from {}", remote_name))?; + + // 2.) Check if its a branch + let expected_branch_ref = format!("refs/heads/{}", revision); + let mut command = safe_command( + format!( + "git ls-remote --quiet --branches \"{}\" \"{}\"", + remote_name, expected_branch_ref + ), + repo_path, + )?; + let mut child = command + .stdout(Stdio::piped()) + .spawn() + .with_context(|| format!("Failed to spawn"))?; + + let stdout = child.stdout.take().expect("failed to capture stdout"); + let reader = BufReader::new(stdout); + + if let Some(parts) = reader + .lines() + .filter_map(|line| line.ok()) + .map(|line| line.split("\t").map(String::from).collect::>()) + .filter(|parts| parts.len() == 2) + .find(|parts| parts[1] == expected_branch_ref) + { + // we found a branch that matches. + child.kill()?; // Abort the child process. + child.wait()?; // Cleanup + return Ok(parts[0].to_string()); // Return early. + } + + child + .wait() + .with_context(|| format!("Failed to list branches tags from {}", remote_name))?; + + // We are here because revision isn't a tag or a branch. + // First, we'll check if revision itself *could* be a SHA1. + // If it doesn't look like one, we'll return early. + let pattern = r"^[0-9a-f]{1,40}$"; + let re = Regex::new(pattern).expect("Invalid regex"); + if !re.is_match(revision.as_bytes()) { + return Err(anyhow!("cannot resolve {} into a Git SHA1", revision)); + } + + safe_command(format!("git fetch --quiet \"{}\"", remote_name), repo_path)? + .stdout(Stdio::null()) + .status() + .with_context(|| format!("unable to fetch objects from remote {}", remote_name))?; + + // 3.) Check if any branch in remote contains the SHA1: + // It seems that the only way to do this is to list the branches that contain the SHA1 + // and check if it belongs in the remote. + let remote_branch_prefix = format!("refs/remotes/{}/", remote_name); + let mut command = safe_command( + format!( + "git branch --format=\"%(refname)\" -a --contains \"{}\"", + revision + ), + repo_path, + )?; + let mut child = command.stdout(Stdio::piped()).spawn().with_context(|| { + format!( + "Failed to find branches containing commit {} from {}", + revision, remote_name + ) + })?; + + let stdout = child.stdout.take().expect("failed to capture stdout"); + let reader = BufReader::new(stdout); + if let Some(_) = reader + .lines() + .filter_map(|line| line.ok()) + .find(|line| line.clone().starts_with(&remote_branch_prefix)) + { + // we found a remote ref that contains the commit sha + child.kill()?; // Abort the child process. + child.wait()?; // Cleanup + return Ok(revision.to_string()); // Return early. + } + + child.wait().with_context(|| { + format!( + "Failed to list branches from {} containing SHA1 {}", + remote_name, revision + ) + })?; + + return Err(anyhow!( + "cannot find revision {} in remote {}", + revision, + remote_name + )); +} + +fn safe_command(command: String, cwd: &Path) -> Result { + let command_vec = shell_words::split(&command).map_err(anyhow::Error::new)?; + let mut command = Command::new(&command_vec[0]); + command.args(&command_vec[1..]).current_dir(cwd); + Ok(command) +} + +fn git_to_revision(repo_path: &Path, remote_name: &str, revision: &str) -> Result<()> { + // Download the object from the remote + safe_command( + format!("git fetch --quiet \"{}\" \"{}\"", remote_name, revision), + repo_path, + )? + .status() + .with_context(|| { + format!( + "Error with fetching revision {} in {}", + revision, + repo_path.display() + ) + })?; + + // Normalize the revision into the SHA. + let commit_sha = git_resolve_revision(repo_path, remote_name, revision)?; + + safe_command( + format!( + "git -c advice.detachedHead=false checkout --quiet \"{}\"", + commit_sha + ), + repo_path, + )? + .stdout(Stdio::null()) + .current_dir(repo_path) + .status() + .with_context(|| { + format!( + "Failed to checkout SHA {} in {}", + commit_sha, + repo_path.display() + ) + })?; + + Ok(()) +} + +pub fn git_is_working_dir_clean(target_dir: &Path) -> Result { + // We use the Git plumbing diff-index command to tell us of files that has changed, + // both staged and unstaged. + let status = safe_command("git diff-index --quiet HEAD --".to_string(), target_dir)? + .status() + .with_context(|| format!("Failed to execute process in {}", target_dir.display()))?; - Ok(has_diff) + // With the --quiet flag, it will return a 0 exit-code if no files has changed. + Ok(status.success()) } pub fn create_theme_filename_without_extension(item: &ConfigItem) -> Result { diff --git a/tests/cli_install_subcommand_tests.rs b/tests/cli_install_subcommand_tests.rs index bcc7c79..b8682b0 100644 --- a/tests/cli_install_subcommand_tests.rs +++ b/tests/cli_install_subcommand_tests.rs @@ -1,5 +1,7 @@ mod utils; +use std::{process::Command, str}; + use crate::utils::{setup, write_to_file}; use anyhow::Result; @@ -164,3 +166,195 @@ fn test_cli_install_subcommand_with_setup_quiet_flag() -> Result<()> { cleanup()?; Ok(()) } + +#[test] +fn test_cli_install_subcommand_with_tag_revision() -> Result<()> { + // ------- + // Arrange + // ------- + let (config_path, repo_path, command_vec, cleanup) = + setup("test_cli_install_subcommand_with_tag_revision", "install")?; + let config_content = r##"[[items]] +path = "https://github.com/tinted-theming/tinted-jqp" +name = "tinted-jqp" +themes-dir = "themes" +revision = "tinty-test-tag-01" +"##; + write_to_file(&config_path, config_content)?; + + let mut repo_path = repo_path.clone(); + repo_path.push("repos"); + repo_path.push("tinted-jqp"); + + // --- + // Act + // --- + let (_, _) = utils::run_command(command_vec).unwrap(); + + let output = Command::new("git") + .current_dir(repo_path) + .args(vec!["rev-parse", "--verify", "HEAD"]) + .output() + .expect("Failed to execute git rev-parse --verify HEAD"); + + let stdout = str::from_utf8(&output.stdout).expect("Not valid UTF-8"); + + // ------ + // Assert + // ------ + let expected_revision = "b6c6a7803c2669022167c9cfc5efb3dc3928507d"; + let has_match = stdout.lines().any(|line| line == expected_revision); + cleanup()?; + assert!( + has_match == true, + "Expected revision {} not found", + expected_revision, + ); + + Ok(()) +} + +#[test] +fn test_cli_install_subcommand_with_branch_revision() -> Result<()> { + // ------- + // Arrange + // ------- + let (config_path, repo_path, command_vec, cleanup) = setup( + "test_cli_install_subcommand_with_branch_revision", + "install", + )?; + let config_content = r##"[[items]] +path = "https://github.com/tinted-theming/tinted-jqp" +name = "tinted-jqp" +themes-dir = "themes" +revision = "tinty-test-01" +"##; + write_to_file(&config_path, config_content)?; + + // --- + // Act + // --- + let (_, _) = utils::run_command(command_vec).unwrap(); + + let mut repo_path = repo_path.clone(); + repo_path.push("repos"); + repo_path.push("tinted-jqp"); + + let output = Command::new("git") + .current_dir(repo_path) + .args(vec!["rev-parse", "--verify", "HEAD"]) + .output() + .expect("Failed to execute git rev-parse --verify HEAD"); + + let stdout = str::from_utf8(&output.stdout).expect("Not valid UTF-8"); + + // ------ + // Assert + // ------ + let expected_revision = "43b36ed5eadad59a5027e442330d2485b8607b34"; + let has_match = stdout.lines().any(|line| line == expected_revision); + cleanup()?; + assert!( + has_match == true, + "Expected revision {} not found", + expected_revision, + ); + + Ok(()) +} + +#[test] +fn test_cli_install_subcommand_with_commit_sha1_revision() -> Result<()> { + // ------- + // Arrange + // ------- + let (config_path, repo_path, command_vec, cleanup) = setup( + "test_cli_install_subcommand_with_commit_sha1_revision", + "install", + )?; + let config_content = r##"[[items]] +path = "https://github.com/tinted-theming/tinted-jqp" +name = "tinted-jqp" +themes-dir = "themes" +revision = "f998d17414a7218904bb5b4fdada5daa2b2d9d5e" +"##; + write_to_file(&config_path, config_content)?; + + // --- + // Act + // --- + let (_, _) = utils::run_command(command_vec).unwrap(); + + let mut repo_path = repo_path.clone(); + repo_path.push("repos"); + repo_path.push("tinted-jqp"); + + let output = Command::new("git") + .current_dir(repo_path) + .args(vec!["rev-parse", "--verify", "HEAD"]) + .output() + .expect("Failed to execute git rev-parse --verify HEAD"); + + let stdout = str::from_utf8(&output.stdout).expect("Not valid UTF-8"); + + // ------ + // Assert + // ------ + // This SHA1 is only reachable through the tinted-test-01 branch, but is not the tip of that + // branch. + let expected_revision = "f998d17414a7218904bb5b4fdada5daa2b2d9d5e"; + let has_match = stdout.lines().any(|line| line == expected_revision); + cleanup()?; + assert!( + has_match == true, + "Expected revision {} not found", + expected_revision, + ); + + Ok(()) +} + +#[test] +fn test_cli_install_subcommand_with_non_existent_revision() -> Result<()> { + // ------- + // Arrange + // ------- + let (config_path, repo_path, command_vec, cleanup) = setup( + "test_cli_install_subcommand_with_non_existent_revision", + "install", + )?; + let config_content = r##"[[items]] +path = "https://github.com/tinted-theming/tinted-jqp" +name = "tinted-jqp" +themes-dir = "themes" +revision = "invalid-revision" +"##; + write_to_file(&config_path, config_content)?; + + let mut repo_path = repo_path.clone(); + repo_path.push("repos"); + repo_path.push("tinted-jqp"); + + // --- + // Act + // --- + let (_, stderr) = utils::run_command(command_vec).unwrap(); + + // ------ + // Assert + // ------ + let path_exists = repo_path.exists(); + cleanup()?; + assert!( + stderr.contains("cannot resolve invalid-revision"), + "Expected revision not found", + ); + + assert!( + !path_exists, + "Expected repo path {} to not exist", + repo_path.display(), + ); + + Ok(()) +} diff --git a/tests/cli_update_subcommand_tests.rs b/tests/cli_update_subcommand_tests.rs index cc7dfe0..66d0bcf 100644 --- a/tests/cli_update_subcommand_tests.rs +++ b/tests/cli_update_subcommand_tests.rs @@ -1,7 +1,10 @@ mod utils; +use std::process::Command; + use crate::utils::{setup, REPO_NAME}; use anyhow::Result; +use utils::write_to_file; #[test] fn test_cli_update_subcommand_without_setup() -> Result<()> { @@ -93,3 +96,327 @@ fn test_cli_update_subcommand_with_setup_quiet_flag() -> Result<()> { Ok(()) } + +#[test] +fn test_cli_update_subcommand_with_new_remote() -> Result<()> { + // ------- + // Arrange + // ------- + let (config_path, data_path, command_vec, cleanup) = + setup("test_cli_update_subcommand_with_new_remote", "update")?; + let expected_output = "tinted-jqp up to date"; + + let config_content = r##"[[items]] +path = "https://github.com/bezhermoso/tinted-jqp" +name = "tinted-jqp" +themes-dir = "themes" +"##; + + write_to_file(&config_path, config_content)?; + + // --- + // Act + // --- + utils::run_install_command(&config_path, &data_path)?; + + // Replace the remote with a new one + let config_content = r##"[[items]] +path = "https://github.com/tinted-theming/tinted-jqp" +name = "tinted-jqp" +themes-dir = "themes" +"##; + write_to_file(&config_path, config_content)?; + let (stdout, _) = utils::run_command(command_vec).unwrap(); + + let mut data_path = data_path.clone(); + data_path.push("repos"); + data_path.push("tinted-jqp"); + + let output = Command::new("git") + .args(vec!["remote", "get-url", "origin"]) + .current_dir(&data_path) + .output()?; + + let remote_stdout = String::from_utf8(output.stdout)?; + + // ------ + // Assert + // ------ + cleanup()?; + assert!( + stdout.contains(expected_output), + "stdout does not contain the expected output" + ); + let remote_url = "https://github.com/tinted-theming/tinted-jqp"; + let has_match = remote_stdout.lines().any(|line| line == remote_url); + + assert!( + has_match, + "Expected origin remote to point to URL {}", + remote_url + ); + + Ok(()) +} + +#[test] +fn test_cli_update_subcommand_with_new_revision() -> Result<()> { + // ------- + // Arrange + // ------- + let (config_path, data_path, command_vec, cleanup) = + setup("test_cli_update_subcommand_with_new_revision", "update")?; + let expected_output = "tinted-jqp up to date"; + + let config_content = r##"[[items]] +path = "https://github.com/tinted-theming/tinted-jqp" +name = "tinted-jqp" +themes-dir = "themes" +"##; + + write_to_file(&config_path, config_content)?; + + // --- + // Act + // --- utils::run_install_command(&config_path, &data_path)?; + + // Replace the remote with a new one + utils::run_install_command(&config_path, &data_path)?; + + let config_content = r##"[[items]] +path = "https://github.com/tinted-theming/tinted-jqp" +name = "tinted-jqp" +themes-dir = "themes" +revision = "tinty-test-tag-01" +"##; + write_to_file(&config_path, config_content)?; + let (stdout, _) = utils::run_command(command_vec).unwrap(); + + let mut data_path = data_path.clone(); + data_path.push("repos"); + data_path.push("tinted-jqp"); + + println!( + "repo_path: {}, exists?: {}", + data_path.display(), + data_path.exists() + ); + + let output = Command::new("git") + .args(vec!["rev-parse", "--verify", "HEAD"]) + .current_dir(&data_path) + .output()?; + + let rev_parse_out = String::from_utf8(output.stdout)?; + + // ------ + // Assert + // ------ + cleanup()?; + assert!( + stdout.contains(expected_output), + "stdout does not contain expected output" + ); + let expected_revision = "b6c6a7803c2669022167c9cfc5efb3dc3928507d"; + let has_match = rev_parse_out.lines().any(|line| line == expected_revision); + + assert!(has_match, "Expected revision {}", expected_revision); + + Ok(()) +} + +#[test] +fn test_cli_update_subcommand_with_new_remote_but_invalid_tag_revision() -> Result<()> { + // ------- + // Arrange + // ------- + let (config_path, data_path, command_vec, cleanup) = setup( + "test_cli_update_subcommand_with_new_remote_but_invalid_tag_revision", + "update", + )?; + let expected_output = "tinted-jqp up to date"; + let config_content = r##"[[items]] +path = "https://github.com/tinted-theming/tinted-jqp" +name = "tinted-jqp" +themes-dir = "themes" +"##; + + write_to_file(&config_path, config_content)?; + + // --- + // Act + // --- + utils::run_install_command(&config_path, &data_path)?; + + // Replace the remote with a new one + // tinty-test-tag-01 exist in tinted-theming but not on this one. + let config_content = r##"[[items]] +path = "https://github.com/bezhermoso/tinted-jqp" +name = "tinted-jqp" +themes-dir = "themes" +revision = "tinty-test-tag-01" +"##; + write_to_file(&config_path, config_content)?; + let (stdout, stderr) = utils::run_command(command_vec).unwrap(); + + let mut data_path = data_path.clone(); + data_path.push("repos"); + data_path.push("tinted-jqp"); + + let output = Command::new("git") + .args(vec!["remote", "get-url", "origin"]) + .current_dir(&data_path) + .output()?; + + let remote_out = String::from_utf8(output.stdout)?; + + // ------ + // Assert + // ------ + cleanup()?; + assert!( + !stdout.contains(expected_output), + "stdout contains unexpected output" + ); + assert!( + stderr.contains("cannot resolve tinty-test-tag-01"), + "stderr does not contain the expected output" + ); + let expected_remote_url = "https://github.com/tinted-theming/tinted-jqp"; + let has_match = remote_out.lines().any(|line| line == expected_remote_url); + + assert!(has_match, "Expected remote URL {}", expected_remote_url); + + Ok(()) +} + +#[test] +fn test_cli_update_subcommand_with_new_remote_but_invalid_branch_revision() -> Result<()> { + // ------- + // Arrange + // ------- + let (config_path, data_path, command_vec, cleanup) = setup( + "test_cli_update_subcommand_with_new_remote_but_invalid_branch_revision", + "update", + )?; + let expected_output = "tinted-jqp up to date"; + let config_content = r##"[[items]] +path = "https://github.com/tinted-theming/tinted-jqp" +name = "tinted-jqp" +themes-dir = "themes" +"##; + + write_to_file(&config_path, config_content)?; + + // --- + // Act + // --- + utils::run_install_command(&config_path, &data_path)?; + + // Replace the remote with a new one + // tinty-test-01 exist in tinted-theming but not on this one. + let config_content = r##"[[items]] +path = "https://github.com/bezhermoso/tinted-jqp" +name = "tinted-jqp" +themes-dir = "themes" +revision = "tinty-test-01" +"##; + write_to_file(&config_path, config_content)?; + let (stdout, stderr) = utils::run_command(command_vec).unwrap(); + + let mut data_path = data_path.clone(); + data_path.push("repos"); + data_path.push("tinted-jqp"); + + let output = Command::new("git") + .args(vec!["remote", "get-url", "origin"]) + .current_dir(&data_path) + .output()?; + + let remote_out = String::from_utf8(output.stdout)?; + + // ------ + // Assert + // ------ + cleanup()?; + assert!( + !stdout.contains(expected_output), + "stdout contains unexpected output" + ); + assert!( + stderr.contains("cannot resolve tinty-test-01"), + "stderr does not contain the expected output" + ); + let expected_remote_url = "https://github.com/tinted-theming/tinted-jqp"; + let has_match = remote_out.lines().any(|line| line == expected_remote_url); + + assert!(has_match, "Expected remote URL {}", expected_remote_url); + + Ok(()) +} + +#[test] +fn test_cli_update_subcommand_with_new_remote_but_invalid_commit_sha1_revision() -> Result<()> { + // ------- + // Arrange + // ------- + let (config_path, data_path, command_vec, cleanup) = setup( + "test_cli_update_subcommand_with_new_remote_but_commit_sha1_revision", + "update", + )?; + let expected_output = "tinted-jqp up to date"; + let config_content = r##"[[items]] +path = "https://github.com/tinted-theming/tinted-jqp" +name = "tinted-jqp" +themes-dir = "themes" +"##; + + write_to_file(&config_path, config_content)?; + + // --- + // Act + // --- + utils::run_install_command(&config_path, &data_path)?; + + // Replace the remote with a new one + // This commit SHA only exist in tinted-theming but not on this one. + let config_content = r##"[[items]] +path = "https://github.com/bezhermoso/tinted-jqp" +name = "tinted-jqp" +themes-dir = "themes" +revision = "43b36ed5eadad59a5027e442330d2485b8607b34" +"##; + write_to_file(&config_path, config_content)?; + let (stdout, stderr) = utils::run_command(command_vec).unwrap(); + + let mut data_path = data_path.clone(); + data_path.push("repos"); + data_path.push("tinted-jqp"); + + let output = Command::new("git") + .args(vec!["remote", "get-url", "origin"]) + .current_dir(&data_path) + .output()?; + + let remote_out = String::from_utf8(output.stdout)?; + + print!("{}", stdout); + // ------ + // Assert + // ------ + cleanup()?; + assert!( + !stdout.contains(expected_output), + "stdout contains unexpected output" + ); + assert!( + stderr.contains("cannot find revision 43b36ed5eadad59a5027e442330d2485b8607b34"), + "stderr does not contain the expected output" + ); + let expected_remote_url = "https://github.com/tinted-theming/tinted-jqp"; + let has_match = remote_out.lines().any(|line| line == expected_remote_url); + + assert!(has_match, "Expected remote URL {}", expected_remote_url); + + Ok(()) +}