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(())
+}